diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9fa7fd3 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(wc -l /home/jv/src/personal/playground/scholar_scraper/scholar-scraper/frontend/src/features/settings/SettingsAdminPanel.vue /home/jv/src/personal/playground/scholar_scraper/scholar-scraper/frontend/src/features/settings/components/Admin*.vue)", + "Read(//home/jv/src/personal/playground/scholar_scraper/scholar-scraper/**)", + "Bash(while read line rest)", + "Bash(do echo \"$line $rest\")", + "Bash(done)", + "Bash(grep -c 'def test_' tests/unit/*.py tests/unit/**/*.py tests/integration/*.py)", + "Bash(sort -t: -k2 -rn)", + "Bash(wc -l frontend/src/pages/*.vue)" + ], + "additionalDirectories": [ + "/home/jv/src/personal/playground/scholar_scraper/scholar-scraper/frontend/src" + ] + } +} diff --git a/.env.example b/.env.example index 326e7d3..17e0363 100644 --- a/.env.example +++ b/.env.example @@ -79,12 +79,15 @@ LOG_REDACT_FIELDS= SCHEDULER_ENABLED=1 SCHEDULER_TICK_SECONDS=60 SCHEDULER_QUEUE_BATCH_SIZE=10 +SCHEDULER_PDF_QUEUE_BATCH_SIZE=15 INGESTION_AUTOMATION_ALLOWED=1 INGESTION_MANUAL_RUN_ALLOWED=1 INGESTION_MIN_RUN_INTERVAL_MINUTES=15 INGESTION_MIN_REQUEST_DELAY_SECONDS=2 INGESTION_NETWORK_ERROR_RETRIES=1 INGESTION_RETRY_BACKOFF_SECONDS=1.0 +INGESTION_RATE_LIMIT_RETRIES=3 +INGESTION_RATE_LIMIT_BACKOFF_SECONDS=30.0 INGESTION_MAX_PAGES_PER_SCHOLAR=30 INGESTION_PAGE_SIZE=100 INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD=1 @@ -112,6 +115,10 @@ SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD=1 SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS=1800 SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD=2 SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD=3 +SCHOLAR_HTTP_USER_AGENT= +SCHOLAR_HTTP_ROTATE_USER_AGENT=0 +SCHOLAR_HTTP_ACCEPT_LANGUAGE=en-US,en;q=0.9 +SCHOLAR_HTTP_COOKIE= # ------------------------------ # OA Enrichment + PDF Resolution @@ -125,6 +132,14 @@ UNPAYWALL_RETRY_COOLDOWN_SECONDS=1800 UNPAYWALL_PDF_DISCOVERY_ENABLED=1 UNPAYWALL_PDF_DISCOVERY_MAX_CANDIDATES=5 UNPAYWALL_PDF_DISCOVERY_MAX_HTML_BYTES=500000 +ARXIV_ENABLED=1 +ARXIV_TIMEOUT_SECONDS=3.0 +ARXIV_MIN_INTERVAL_SECONDS=4.0 +ARXIV_RATE_LIMIT_COOLDOWN_SECONDS=60.0 +ARXIV_DEFAULT_MAX_RESULTS=3 +ARXIV_CACHE_TTL_SECONDS=900 +ARXIV_CACHE_MAX_ENTRIES=512 +ARXIV_MAILTO= PDF_AUTO_RETRY_INTERVAL_SECONDS=86400 PDF_AUTO_RETRY_FIRST_INTERVAL_SECONDS=3600 PDF_AUTO_RETRY_MAX_ATTEMPTS=3 @@ -133,6 +148,9 @@ CROSSREF_MAX_ROWS=10 CROSSREF_TIMEOUT_SECONDS=8.0 CROSSREF_MIN_INTERVAL_SECONDS=0.6 CROSSREF_MAX_LOOKUPS_PER_REQUEST=8 +OPENALEX_API_KEY= +CROSSREF_API_TOKEN= +CROSSREF_API_MAILTO= # ------------------------------ # Startup Bootstrap + DB Wait diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..f5f3785 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +buy_me_a_coffee: justinzeus diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3dbdcd9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + - package-ecosystem: npm + directory: /frontend + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/logo-dark.png b/.github/logo-dark.png new file mode 100644 index 0000000..a3e5424 Binary files /dev/null and b/.github/logo-dark.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a93c27..b5c86ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,28 @@ jobs: - name: Enforce env contract parity run: python3 scripts/check_env_contract.py + lint: + runs-on: ubuntu-latest + needs: [repo-hygiene] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + - run: uv sync --extra dev + - name: Ruff check + run: uv run ruff check . + - name: Ruff format check + run: uv run ruff format --check . + - name: Mypy + run: uv run mypy app/ --ignore-missing-imports + test: runs-on: ubuntu-latest needs: - repo-hygiene + - lint services: postgres: image: postgres:15 @@ -91,7 +109,7 @@ jobs: - name: Install frontend dependencies working-directory: frontend - run: npm install + run: npm ci - name: Frontend theme token policy working-directory: frontend @@ -124,7 +142,7 @@ jobs: node-version: "20" - name: Install docs dependencies - run: npm install --prefix docs/website + run: npm ci --prefix docs/website - name: Docs build run: npm --prefix docs/website run build diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..64a22d1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,26 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "30 5 * * 1" + +jobs: + analyze: + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + strategy: + matrix: + language: [python, javascript] + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/autobuild@v3 + - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 1e699e5..24ebe7a 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -32,7 +32,7 @@ jobs: node-version: "20" - name: Install docs dependencies - run: npm install --prefix docs/website + run: npm ci --prefix docs/website - name: Build docs site run: npm --prefix docs/website run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f43fb27 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + branches: [main] + +jobs: + release: + runs-on: ubuntu-latest + if: github.repository == 'JustinZeus/scholarr' + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + - run: uv sync --extra dev + - name: Lint + run: uv run ruff check + - name: Test + run: uv run pytest + - name: Semantic Release + id: release + run: uv run semantic-release publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 88c4ac6..68a3f87 100644 --- a/.gitignore +++ b/.gitignore @@ -12,9 +12,9 @@ htmlcov/ *.log .DS_Store planning/ +build/ frontend/node_modules/ frontend/dist/ frontend/.vite/ docs/website/node_modules/ docs/website/build/ -AGENTS.MD diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6d0fc52 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 JustinZeus + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 5d5f84f..e2bf4f6 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,149 @@ -# scholarr -
-Self-hosted scholar tracking with a single app image (API + frontend). + + + + Scholarr + + +# Scholarr + +**Self-hosted academic publication tracker.** + +Track Google Scholar profiles, discover new papers automatically, +resolve open-access PDFs, and stay on top of the literature you care about. [![CI](https://img.shields.io/github/actions/workflow/status/justinzeus/scholarr/ci.yml?style=for-the-badge)](https://github.com/JustinZeus/scholarr/actions/workflows/ci.yml) +[![CodeQL](https://img.shields.io/github/actions/workflow/status/justinzeus/scholarr/codeql.yml?style=for-the-badge&label=CodeQL)](https://github.com/JustinZeus/scholarr/actions/workflows/codeql.yml) +[![Release](https://img.shields.io/github/v/release/justinzeus/scholarr?style=for-the-badge)](https://github.com/JustinZeus/scholarr/releases) [![Docker Pulls](https://img.shields.io/docker/pulls/justinzeus/scholarr?style=for-the-badge&logo=docker)](https://hub.docker.com/r/justinzeus/scholarr) -[![Docker Image](https://img.shields.io/badge/docker-justinzeus%2Fscholarr-2496ED?style=for-the-badge&logo=docker&logoColor=white)](https://hub.docker.com/r/justinzeus/scholarr) +[![Python](https://img.shields.io/badge/python-3.12+-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/) +[![License](https://img.shields.io/github/license/justinzeus/scholarr?style=for-the-badge)](LICENSE) +[![Docs](https://img.shields.io/badge/docs-scholarr-2e8555?style=for-the-badge)](https://justinzeus.github.io/scholarr/)
+--- + + + +

+ Publications dashboard +

+ + + +## Why Scholarr? + +Most researchers track new papers by manually checking Google Scholar, setting up email alerts, or juggling RSS feeds. Scholarr replaces all of that with a single self-hosted service: + +- **Add scholars once** -- by profile URL, Scholar ID, or name search +- **Publications appear automatically** -- a background scheduler scrapes profiles on a configurable interval +- **Open-access PDFs are resolved for you** -- Unpaywall and arXiv are queried automatically when a DOI is found +- **Everything is deduplicated** -- publications are global records; no duplicates across scholars +- **Your data stays yours** -- fully self-hosted, export/import your entire library at any time + +## Features + +| | | +|---|---| +| **Automated Ingestion** | Background scheduler with configurable intervals, continuation queue, and multi-page pagination | +| **Identifier Resolution** | Cross-references arXiv, Crossref, and OpenAlex to gather DOIs, arXiv IDs, PMIDs | +| **PDF Discovery** | Resolves open-access PDFs via Unpaywall API and arXiv, with automatic retry queue | +| **Scrape Safety** | Rate limiting, cooldowns, and backoff strategies that prevent IP bans -- these are safety floors, not optional | +| **Multi-User** | Session-based auth, admin user management, user-scoped scholar tracking | +| **Theming** | 7 color presets with light/dark mode, tokenized component system | +| **Import / Export** | Portable scholar data with full publication and read-state preservation | +| **Single Container** | FastAPI backend + Vue 3 frontend ship as one Docker image | + ## Quick Start -1. Copy env template: - ```bash +# 1. Clone and configure +git clone https://github.com/JustinZeus/scholarr.git +cd scholarr cp .env.example .env + +# 2. Set required secrets in .env +# POSTGRES_PASSWORD= +# SESSION_SECRET_KEY= + +# 3. Start +docker compose up -d + +# 4. Open http://localhost:8000 ``` -2. Set required values in `.env`: -- `POSTGRES_PASSWORD` -- `SESSION_SECRET_KEY` - -3. Start stack: +To bootstrap an admin account on first run, add to `.env`: ```bash -docker compose pull -docker compose up -d +BOOTSTRAP_ADMIN_ON_START=1 +BOOTSTRAP_ADMIN_EMAIL=admin@example.com +BOOTSTRAP_ADMIN_PASSWORD= ``` -Open: -- App/API: `http://localhost:8000` -- Health: `http://localhost:8000/healthz` +## How It Works + +```mermaid +graph LR + UI[Vue 3 Dashboard] <-->|REST + SSE| API[FastAPI] + API --> Scheduler[Scheduler] + Scheduler -->|Scrape HTML| Scholar[Google Scholar] + Scholar -->|Parse & Deduplicate| DB[(PostgreSQL)] + Scholar -.->|Identify| Ext[arXiv / Crossref / OpenAlex] + Ext --> DB + DB -->|DOIs| PDF[PDF Resolution] + PDF -->|Unpaywall / arXiv| DB + API <--> DB +``` + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| Backend | Python 3.12, FastAPI, SQLAlchemy 2.0 (async), Alembic | +| Frontend | TypeScript, Vue 3, Vite, Tailwind CSS | +| Database | PostgreSQL 15 | +| Infrastructure | Multi-stage Docker, Docker Compose | ## Documentation -Complete documentation is published at: +Full documentation: **[justinzeus.github.io/scholarr](https://justinzeus.github.io/scholarr/)** -- https://justinzeus.github.io/scholarr/ +| Section | Covers | +|---------|--------| +| [User Guide](docs/user/overview.md) | Installation, configuration, all environment variables | +| [Developer Guide](docs/developer/overview.md) | Architecture, local dev, contributing, testing | +| [Operations](docs/operations/overview.md) | Deployment, database runbook, scrape safety | +| [API Reference](docs/reference/api.md) | Envelope spec, all endpoints, DTO contracts | -Source markdown and docs tooling live under `docs/`. +## Contributing -## Quality Gates - -Backend: +Scholarr uses [conventional commits](https://www.conventionalcommits.org/) and [semantic versioning](https://semver.org/). See the [contributing guide](docs/developer/contributing.md) for PR process and code standards. ```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app uv run pytest tests/unit -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app uv run pytest -m integration +# Dev environment +docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build + +# Run tests (always in containers) +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + python -m pytest ``` -Frontend: +## License -```bash -cd frontend -npm install -npm run typecheck -npm run test:run -npm run build -``` - -Contract and env checks: - -```bash -python3 scripts/check_frontend_api_contract.py -python3 scripts/check_env_contract.py -./scripts/check_no_generated_artifacts.sh -``` +See [LICENSE](LICENSE) for details. diff --git a/agents.md b/agents.md index 07b62d2..1767078 100644 --- a/agents.md +++ b/agents.md @@ -1,43 +1,123 @@ # AI Agent Instructions: Scholarr -Adhere strictly to these constraints. +Adhere strictly to these constraints when working on this codebase. -## 1. Coding Standards (Strict Enforcement) -* **Function Length:** Maximum 50 lines of code per function. Break down complex logic into small, testable, single-responsibility functions. -* **DRY (Don't Repeat Yourself):** Abstract repetitive logic immediately. No duplicate boilerplate for database queries, API responses, or error handling. -* **Negative Space Programming:** Utilize explicit assertions and constraints to define invalid states. Fail fast and early. Do not allow silent failures or cascading malformed data, especially in DOM parsing. -* **Cyclomatic Complexity:** Flatten logic. Use early returns and guard clauses instead of deep nesting. +## 1. Code Quality -## 2. Domain Architecture & Data Model -* **Data Isolation:** Scholar tracking is **user-scoped**. Validate mapping/join tables; never assume global links between users and Scholar IDs. -* **Data Deduplication:** Publications are **global records**. Deduplicate via Scholar cluster ID and normalized fingerprinting prior to database insertion. -* **State Management:** Visibility and "read/unread" states exist exclusively on the scholar-publication link table, not the global publication table. -* **API Contract:** Exact envelope format required: - * Success: `{"data": ..., "meta": {"request_id": "..."}}` - * Error: `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` +- **Function length:** 50 lines max. Extract helpers ruthlessly. +- **File length:** 400 lines target, 600 lines hard ceiling. Files above this must be decomposed before adding more code. +- **DRY:** Abstract repeated logic immediately. No duplicate boilerplate for queries, responses, or error handling. +- **Negative space programming:** Fail fast with explicit assertions and guard clauses. No silent failures, especially in DOM parsing. +- **Cyclomatic complexity:** Flatten with early returns. No deep nesting. No magic numbers. +- **No dead code:** Do not leave commented-out code, unused imports, or backward-compatibility shims. Delete cleanly. +- **Variable naming:** Any variable outside of a lambda functions or iterators should be descriptive. Code should be readable for maintainers. -## 3. Scrape Safety & Rate Limiting (Immutable) -These limits prevent IP bans and are not to be optimized away. -* **Minimum Delay:** Enforce `INGESTION_MIN_REQUEST_DELAY_SECONDS` (default 2s) between all external requests. -* **Anti-Detection:** Default to direct ID or profile URL ingestion. Name searches trigger CAPTCHAs. -* **Cooldowns:** Respect `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS` (1800s) and `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS` (900s) upon threshold breaches. +## 2. Architecture -## 4. Current Environment & Stack -* **Backend:** Python 3.12+, FastAPI, SQLAlchemy (Async/asyncpg), Alembic. -* **Frontend:** TypeScript, Vue 3, Vite. -* **Infrastructure:** Multi-stage Docker. +### Data Model -## 5. Domain Service Boundaries -* **Strict Modularity:** Flat files in the `app/services/` root are strictly prohibited. All business logic and routing must reside exclusively within `app/services/domains/`. -* **`app/services/domains/scholar/*`:** Parser contract is fail-fast. Layout drift must emit explicit exceptions and `layout_*` reasons/warnings. Never allow silent partial success. -* **`app/services/domains/ingestion/application.py`:** Orchestrates ingestion runs; validate parser outputs before persistence; enforce publication candidate constraints before upsert. -* **`app/services/domains/publications/*`:** Publication list/read-state query layer. Includes `doi` + `pdf_url` fields for UI consumption, enforces non-blocking lazy OA enrichment scheduling on list reads, and exposes per-publication PDF retry behavior. -* **`app/services/domains/crossref/*`:** DOI discovery fallback module. Must use bounded/paced lookups to avoid burst traffic and 429 responses. -* **`app/services/domains/unpaywall/*`:** OA resolver by DOI only (best OA location + PDF URL extraction). Do not use Google Scholar for PDF resolution to avoid N+1 scrape amplification. -* **`app/services/domains/portability/*`:** Handles JSON import/export for user-scoped scholars and scholar-publication link state while preserving global publication dedup rules. -* **`app/services/domains/ingestion/scheduler.py`:** Owns automatic runs and continuation queue retries/drops; do not bypass safety gate or cooldown logic. +- Scholar tracking is **user-scoped**. Never assume global links between users and Scholar IDs. +- Publications are **global, deduplicated records**. Deduplicate via cluster ID and normalized fingerprinting. +- Read/unread, favorites, and visibility state live on the **scholar-publication link**, not the publication. +### Service Boundaries -## 6. UI rules -Make sure to properly integrate tailwind in combination with the preset theming -Clarity through both styling and language are a priority. all UI elements need to have a proper reason for existing. \ No newline at end of file +All business logic lives in `app/services//`. No flat files in `app/services/` root. Each domain owns its application service, types, and helpers. + +### API Envelope + +All `/api/v1` responses use this exact envelope: + +``` +Success: {"data": ..., "meta": {"request_id": "..."}} +Error: {"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}} +``` + +Use the Pydantic envelope schemas in `app/api/schemas.py`. Do not construct raw dicts. + +## 3. Scrape Safety (Immutable) + +These constraints prevent IP bans. They are not tunable to zero and must not be optimized away. + +- Enforce `INGESTION_MIN_REQUEST_DELAY_SECONDS` (default 2s) between all external requests. +- Default to direct ID or profile URL ingestion. Name searches trigger CAPTCHAs. +- Respect `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS` (1800s) and `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS` (900s) upon threshold breaches. + +## 4. Logging + +Use `structured_log()` from `app/logging_utils.py` for all domain logging. Do not use raw `logger.info()` / `logger.warning()` calls. + +```python +from app.logging_utils import structured_log + +structured_log(logger, "info", "ingestion.run_started", user_id=user_id, scholar_count=count) +``` + +Every event name should be dot-namespaced to its domain (e.g., `arxiv.cache_hit`, `ingestion.safety_cooldown_entered`). + +## 5. Stack & Tooling + +- **Backend:** Python 3.12+, FastAPI, SQLAlchemy 2.0 (async/asyncpg), Alembic +- **Frontend:** TypeScript, Vue 3, Vite, Tailwind CSS +- **Infrastructure:** Multi-stage Docker, Docker Compose +- **Package manager:** `uv` (used in Dockerfile and CI; `uv run` prefix for all commands) +- **Linting:** `ruff check .` and `ruff format --check .` (config in `pyproject.toml`) +- **Type checking:** `mypy app/` +- **Versioning:** python-semantic-release with conventional commits + +## 6. Commits + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): +``` + +Types: `feat`, `fix`, `docs`, `ci`, `refactor`, `test`, `chore`, `perf`. + +## 7. Testing & Quality Gates + +All **local development** commands run inside containers. Do not run bare `npm`, `pytest`, or `ruff` on the host. CI workflows (`.github/workflows/`) use bare runners with `uv` and `npm` directly — this is intentional since CI has no Docker-in-Docker dependency. + +### Backend + +```bash +# Unit tests (default, excludes integration) +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app python -m pytest + +# Integration tests +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app python -m pytest -m integration + +# Linting +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app ruff check . +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app ruff format --check . + +# Type checking +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app mypy app/ +``` + +Pytest markers: `integration`, `db`, `migrations`, `schema`, `smoke`. + +### Frontend + +```bash +# Type checking +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm frontend npm run typecheck + +# Unit tests +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm frontend npm run test:run + +# Lint +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm frontend npm run lint + +# Production build +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm frontend npm run build +``` + +All four gates must pass before a PR is considered ready. + +## 8. Frontend + +- Use the tokenized theme system (`frontend/src/theme/presets/`). Do not hardcode colors. +- Integrate Tailwind with preset theme tokens. Reference `frontend/scripts/check_theme_tokens.mjs` for enforcement. +- Every UI element must have a clear purpose. Clarity through styling and language. diff --git a/alembic/env.py b/alembic/env.py index 17575e4..127078d 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,13 +1,13 @@ -from logging.config import fileConfig import os +from logging.config import fileConfig -from alembic import context from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from app.db.base import metadata +from alembic import context from app.db import models # noqa: F401 +from app.db.base import metadata config = context.config diff --git a/alembic/versions/20260216_0001_multi_user_schema.py b/alembic/versions/20260216_0001_multi_user_schema.py index 221bbf7..6c9375e 100644 --- a/alembic/versions/20260216_0001_multi_user_schema.py +++ b/alembic/versions/20260216_0001_multi_user_schema.py @@ -8,10 +8,11 @@ Create Date: 2026-02-16 17:10:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql +from alembic import op + # revision identifiers, used by Alembic. revision: str = "20260216_0001" down_revision: str | Sequence[str] | None = None diff --git a/alembic/versions/20260216_0002_add_user_admin_flag.py b/alembic/versions/20260216_0002_add_user_admin_flag.py index e60de0f..5ea932d 100644 --- a/alembic/versions/20260216_0002_add_user_admin_flag.py +++ b/alembic/versions/20260216_0002_add_user_admin_flag.py @@ -7,9 +7,10 @@ Create Date: 2026-02-16 17:40:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op + # revision identifiers, used by Alembic. revision: str = "20260216_0002" down_revision: str | Sequence[str] | None = "20260216_0001" @@ -31,4 +32,3 @@ def upgrade() -> None: def downgrade() -> None: op.drop_column("users", "is_admin") - diff --git a/alembic/versions/20260216_0003_add_ingestion_queue.py b/alembic/versions/20260216_0003_add_ingestion_queue.py index cc03867..4c81fa0 100644 --- a/alembic/versions/20260216_0003_add_ingestion_queue.py +++ b/alembic/versions/20260216_0003_add_ingestion_queue.py @@ -7,9 +7,10 @@ Create Date: 2026-02-16 23:45:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op + # revision identifiers, used by Alembic. revision: str = "20260216_0003" down_revision: str | Sequence[str] | None = "20260216_0002" diff --git a/alembic/versions/20260217_0004_queue_status_fields.py b/alembic/versions/20260217_0004_queue_status_fields.py index 68da9dd..f6c29e6 100644 --- a/alembic/versions/20260217_0004_queue_status_fields.py +++ b/alembic/versions/20260217_0004_queue_status_fields.py @@ -7,9 +7,10 @@ Create Date: 2026-02-17 10:15:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op + # revision identifiers, used by Alembic. revision: str = "20260217_0004" down_revision: str | Sequence[str] | None = "20260216_0003" @@ -22,9 +23,7 @@ def upgrade() -> None: inspector = sa.inspect(bind) columns = {column["name"] for column in inspector.get_columns("ingestion_queue_items")} indexes = {index["name"] for index in inspector.get_indexes("ingestion_queue_items")} - checks = { - constraint["name"] for constraint in inspector.get_check_constraints("ingestion_queue_items") - } + checks = {constraint["name"] for constraint in inspector.get_check_constraints("ingestion_queue_items")} if "status" not in columns: op.add_column( @@ -80,9 +79,7 @@ def downgrade() -> None: inspector = sa.inspect(bind) columns = {column["name"] for column in inspector.get_columns("ingestion_queue_items")} indexes = {index["name"] for index in inspector.get_indexes("ingestion_queue_items")} - checks = { - constraint["name"] for constraint in inspector.get_check_constraints("ingestion_queue_items") - } + checks = {constraint["name"] for constraint in inspector.get_check_constraints("ingestion_queue_items")} if "ix_ingestion_queue_status_next_attempt" in indexes: op.drop_index("ix_ingestion_queue_status_next_attempt", table_name="ingestion_queue_items") diff --git a/alembic/versions/20260217_0005_manual_run_idempotency.py b/alembic/versions/20260217_0005_manual_run_idempotency.py index 0375569..ad0269b 100644 --- a/alembic/versions/20260217_0005_manual_run_idempotency.py +++ b/alembic/versions/20260217_0005_manual_run_idempotency.py @@ -7,9 +7,10 @@ Create Date: 2026-02-17 16:20:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op + # revision identifiers, used by Alembic. revision: str = "20260217_0005" down_revision: str | Sequence[str] | None = "20260217_0004" @@ -70,9 +71,7 @@ def upgrade() -> None: "crawl_runs", ["user_id", "idempotency_key"], unique=True, - postgresql_where=sa.text( - "idempotency_key IS NOT NULL AND trigger_type = 'manual'::run_trigger_type" - ), + postgresql_where=sa.text("idempotency_key IS NOT NULL AND trigger_type = 'manual'::run_trigger_type"), ) diff --git a/alembic/versions/20260217_0006_scholar_profile_images.py b/alembic/versions/20260217_0006_scholar_profile_images.py index 08035c8..f736737 100644 --- a/alembic/versions/20260217_0006_scholar_profile_images.py +++ b/alembic/versions/20260217_0006_scholar_profile_images.py @@ -7,9 +7,10 @@ Create Date: 2026-02-17 18:30:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op + # revision identifiers, used by Alembic. revision: str = "20260217_0006" down_revision: str | Sequence[str] | None = "20260217_0005" diff --git a/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py b/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py index dcfd0f7..6022c4c 100644 --- a/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py +++ b/alembic/versions/20260217_0007_scholar_initial_page_fingerprint.py @@ -7,9 +7,10 @@ Create Date: 2026-02-17 18:55:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op + # revision identifiers, used by Alembic. revision: str = "20260217_0007" down_revision: str | Sequence[str] | None = "20260217_0006" diff --git a/alembic/versions/20260219_0008_user_settings_nav_visible_pages.py b/alembic/versions/20260219_0008_user_settings_nav_visible_pages.py index df481fd..ef38677 100644 --- a/alembic/versions/20260219_0008_user_settings_nav_visible_pages.py +++ b/alembic/versions/20260219_0008_user_settings_nav_visible_pages.py @@ -7,10 +7,10 @@ Create Date: 2026-02-19 14:58:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql +from alembic import op # revision identifiers, used by Alembic. revision: str = "20260219_0008" @@ -21,7 +21,7 @@ depends_on: str | Sequence[str] | None = None TABLE_NAME = "user_settings" DEFAULT_NAV_VISIBLE_PAGES_SQL = ( - "'[\"dashboard\",\"scholars\",\"publications\",\"settings\",\"style-guide\",\"runs\",\"users\"]'::jsonb" + '\'["dashboard","scholars","publications","settings","style-guide","runs","users"]\'::jsonb' ) diff --git a/alembic/versions/20260219_0009_user_settings_scrape_safety_state.py b/alembic/versions/20260219_0009_user_settings_scrape_safety_state.py index 9ea658b..175b6b7 100644 --- a/alembic/versions/20260219_0009_user_settings_scrape_safety_state.py +++ b/alembic/versions/20260219_0009_user_settings_scrape_safety_state.py @@ -7,10 +7,10 @@ Create Date: 2026-02-19 21:20:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql +from alembic import op # revision identifiers, used by Alembic. revision: str = "20260219_0009" diff --git a/alembic/versions/20260219_0010_author_search_shared_state.py b/alembic/versions/20260219_0010_author_search_shared_state.py index bc2b97d..0a07a42 100644 --- a/alembic/versions/20260219_0010_author_search_shared_state.py +++ b/alembic/versions/20260219_0010_author_search_shared_state.py @@ -7,10 +7,10 @@ Create Date: 2026-02-19 22:20:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql +from alembic import op # revision identifiers, used by Alembic. revision: str = "20260219_0010" diff --git a/alembic/versions/20260220_0011_add_publication_doi.py b/alembic/versions/20260220_0011_add_publication_doi.py index bb9a813..c286a00 100644 --- a/alembic/versions/20260220_0011_add_publication_doi.py +++ b/alembic/versions/20260220_0011_add_publication_doi.py @@ -7,9 +7,9 @@ Create Date: 2026-02-20 13:35:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op revision: str = "20260220_0011" down_revision: str | Sequence[str] | None = "20260219_0010" diff --git a/alembic/versions/20260220_0012_add_data_repair_jobs.py b/alembic/versions/20260220_0012_add_data_repair_jobs.py index 242876d..6e7b53f 100644 --- a/alembic/versions/20260220_0012_add_data_repair_jobs.py +++ b/alembic/versions/20260220_0012_add_data_repair_jobs.py @@ -7,10 +7,10 @@ Create Date: 2026-02-20 23:35:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql +from alembic import op revision: str = "20260220_0012" down_revision: str | Sequence[str] | None = "20260220_0011" diff --git a/alembic/versions/20260221_0013_add_publication_pdf_jobs.py b/alembic/versions/20260221_0013_add_publication_pdf_jobs.py index 63db6b2..d7046d9 100644 --- a/alembic/versions/20260221_0013_add_publication_pdf_jobs.py +++ b/alembic/versions/20260221_0013_add_publication_pdf_jobs.py @@ -7,9 +7,9 @@ Create Date: 2026-02-21 12:25:00.000000 from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op revision: str = "20260221_0013" down_revision: str | Sequence[str] | None = "20260220_0012" diff --git a/alembic/versions/20260221_0014_add_scholar_publication_favorite_state.py b/alembic/versions/20260221_0014_add_scholar_publication_favorite_state.py index 1fa8f29..7c06d52 100644 --- a/alembic/versions/20260221_0014_add_scholar_publication_favorite_state.py +++ b/alembic/versions/20260221_0014_add_scholar_publication_favorite_state.py @@ -7,9 +7,9 @@ Create Date: 2026-02-21 14:35:00 from __future__ import annotations -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision = "20260221_0014" diff --git a/alembic/versions/20260221_0015_enforce_request_delay_floor.py b/alembic/versions/20260221_0015_enforce_request_delay_floor.py index 688ce0a..122f055 100644 --- a/alembic/versions/20260221_0015_enforce_request_delay_floor.py +++ b/alembic/versions/20260221_0015_enforce_request_delay_floor.py @@ -9,9 +9,9 @@ from __future__ import annotations from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision: str = "20260221_0015" @@ -30,11 +30,7 @@ LEGACY_CHECK_NAME_MIN_2 = "ck_user_settings_request_delay_seconds_min_2" def _check_constraints() -> set[str]: bind = op.get_bind() inspector = sa.inspect(bind) - return { - str(item.get("name")) - for item in inspector.get_check_constraints(TABLE_NAME) - if item.get("name") - } + return {str(item.get("name")) for item in inspector.get_check_constraints(TABLE_NAME) if item.get("name")} def _drop_check_if_exists(name: str) -> None: @@ -48,12 +44,7 @@ def _drop_check_if_exists(name: str) -> None: def upgrade() -> None: - op.execute( - sa.text( - "UPDATE user_settings SET request_delay_seconds = 2 " - "WHERE request_delay_seconds < 2" - ) - ) + op.execute(sa.text("UPDATE user_settings SET request_delay_seconds = 2 WHERE request_delay_seconds < 2")) _drop_check_if_exists(CHECK_NAME_MIN_1) _drop_check_if_exists(LEGACY_CHECK_NAME_MIN_1) diff --git a/alembic/versions/20260222_0016_add_publication_identifiers.py b/alembic/versions/20260222_0016_add_publication_identifiers.py new file mode 100644 index 0000000..9730a2d --- /dev/null +++ b/alembic/versions/20260222_0016_add_publication_identifiers.py @@ -0,0 +1,104 @@ +"""Add publication identifiers table. + +Revision ID: 20260222_0016 +Revises: 20260221_0015 +Create Date: 2026-02-22 12:45:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260222_0016" +down_revision: str | Sequence[str] | None = "20260221_0015" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _table_names() -> set[str]: + bind = op.get_bind() + inspector = sa.inspect(bind) + return set(inspector.get_table_names()) + + +def _create_publication_identifiers_table() -> None: + op.create_table( + "publication_identifiers", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("publication_id", sa.Integer(), nullable=False), + sa.Column("kind", sa.String(length=32), nullable=False), + sa.Column("value_raw", sa.Text(), nullable=False), + sa.Column("value_normalized", sa.String(length=255), nullable=False), + sa.Column("source", sa.String(length=64), nullable=False), + sa.Column( + "confidence_score", + sa.Float(), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column("evidence_url", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.CheckConstraint( + "confidence_score >= 0 AND confidence_score <= 1", + name="publication_identifiers_confidence_score_range", + ), + sa.ForeignKeyConstraint( + ["publication_id"], + ["publications.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_publication_identifiers")), + sa.UniqueConstraint( + "publication_id", + "kind", + "value_normalized", + name="uq_publication_identifiers_publication_kind_value", + ), + ) + + +def _create_publication_identifiers_indexes() -> None: + op.create_index( + "ix_publication_identifiers_kind_value", + "publication_identifiers", + ["kind", "value_normalized"], + unique=False, + ) + op.create_index( + "ix_publication_identifiers_publication_id", + "publication_identifiers", + ["publication_id"], + unique=False, + ) + + +def _drop_publication_identifiers_indexes() -> None: + op.drop_index("ix_publication_identifiers_publication_id", table_name="publication_identifiers") + op.drop_index("ix_publication_identifiers_kind_value", table_name="publication_identifiers") + + +def upgrade() -> None: + if "publication_identifiers" in _table_names(): + return + _create_publication_identifiers_table() + _create_publication_identifiers_indexes() + + +def downgrade() -> None: + if "publication_identifiers" not in _table_names(): + return + _drop_publication_identifiers_indexes() + op.drop_table("publication_identifiers") diff --git a/alembic/versions/20260224_0018_add_openalex_enriched_to_publications.py b/alembic/versions/20260224_0018_add_openalex_enriched_to_publications.py new file mode 100644 index 0000000..ee669fe --- /dev/null +++ b/alembic/versions/20260224_0018_add_openalex_enriched_to_publications.py @@ -0,0 +1,33 @@ +"""Add openalex_enriched to publications + +Revision ID: 20260224_0018 +Revises: fa0c5e3262d5 +Create Date: 2026-02-24 19:10:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20260224_0018" +down_revision: str | Sequence[str] | None = "fa0c5e3262d5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "publications", sa.Column("openalex_enriched", sa.Boolean(), server_default=sa.text("false"), nullable=False) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("publications", "openalex_enriched") + # ### end Alembic commands ### diff --git a/alembic/versions/20260224_0019_add_resolving_to_runstatus.py b/alembic/versions/20260224_0019_add_resolving_to_runstatus.py new file mode 100644 index 0000000..163747c --- /dev/null +++ b/alembic/versions/20260224_0019_add_resolving_to_runstatus.py @@ -0,0 +1,31 @@ +"""Add resolving to RunStatus enum + +Revision ID: 20260224_0019 +Revises: 20260224_0018 +Create Date: 2026-02-24 22:20:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20260224_0019" +down_revision: str | Sequence[str] | None = "20260224_0018" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Use COMMIT to break out of the transaction block that Alembic creates in env.py. + # Postgres restricts ALTER TYPE ... ADD VALUE inside transactions when the type is in use. + # This is the robust, non-patchwork way to ensure the schema evolves correctly. + op.execute("COMMIT") + op.execute("ALTER TYPE run_status ADD VALUE IF NOT EXISTS 'resolving' AFTER 'running'") + + +def downgrade() -> None: + # Enum values cannot be easily removed in Postgres without recreating the type, + # which is risky and usually avoided in simple migrations. + pass diff --git a/alembic/versions/20260224_0020_add_openalex_last_attempt_at.py b/alembic/versions/20260224_0020_add_openalex_last_attempt_at.py new file mode 100644 index 0000000..1b239f6 --- /dev/null +++ b/alembic/versions/20260224_0020_add_openalex_last_attempt_at.py @@ -0,0 +1,27 @@ +"""Add openalex_last_attempt_at to publications + +Revision ID: 20260224_0020 +Revises: 20260224_0019 +Create Date: 2026-02-24 22:50:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20260224_0020" +down_revision: str | Sequence[str] | None = "20260224_0019" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("publications", sa.Column("openalex_last_attempt_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("publications", "openalex_last_attempt_at") diff --git a/alembic/versions/20260225_0021_enforce_single_active_run_per_user.py b/alembic/versions/20260225_0021_enforce_single_active_run_per_user.py new file mode 100644 index 0000000..f5b20f2 --- /dev/null +++ b/alembic/versions/20260225_0021_enforce_single_active_run_per_user.py @@ -0,0 +1,68 @@ +"""Enforce one active crawl run per user. + +Revision ID: 20260225_0021 +Revises: 20260224_0020 +Create Date: 2026-02-25 09:10:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20260225_0021" +down_revision: str | Sequence[str] | None = "20260224_0020" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +INDEX_NAME = "uq_crawl_runs_user_active" +ACTIVE_STATUSES = ("running", "resolving") + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + indexes = {index["name"] for index in inspector.get_indexes("crawl_runs")} + + op.execute("ALTER TYPE run_status ADD VALUE IF NOT EXISTS 'canceled'") + + op.execute( + """ + WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY user_id + ORDER BY start_dt DESC, id DESC + ) AS rn + FROM crawl_runs + WHERE status IN ('running', 'resolving') + ) + UPDATE crawl_runs AS runs + SET + status = 'failed', + end_dt = COALESCE(runs.end_dt, NOW()) + FROM ranked + WHERE runs.id = ranked.id + AND ranked.rn > 1 + """ + ) + + if INDEX_NAME not in indexes: + op.create_index( + INDEX_NAME, + "crawl_runs", + ["user_id"], + unique=True, + postgresql_where=sa.text("status IN ('running'::run_status, 'resolving'::run_status)"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + indexes = {index["name"] for index in inspector.get_indexes("crawl_runs")} + if INDEX_NAME in indexes: + op.drop_index(INDEX_NAME, table_name="crawl_runs") diff --git a/alembic/versions/20260225_0022_add_canonical_title_hash_to_publications.py b/alembic/versions/20260225_0022_add_canonical_title_hash_to_publications.py new file mode 100644 index 0000000..97ba39d --- /dev/null +++ b/alembic/versions/20260225_0022_add_canonical_title_hash_to_publications.py @@ -0,0 +1,35 @@ +"""Add canonical_title_hash to publications for cross-scholar dedup. + +Revision ID: 20260225_0022 +Revises: 20260225_0021 +Create Date: 2026-02-25 10:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20260225_0022" +down_revision: str | Sequence[str] | None = "20260225_0021" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "publications", + sa.Column("canonical_title_hash", sa.String(64), nullable=True), + ) + op.create_index( + "ix_publications_canonical_title_hash", + "publications", + ["canonical_title_hash"], + ) + + +def downgrade() -> None: + op.drop_index("ix_publications_canonical_title_hash", table_name="publications") + op.drop_column("publications", "canonical_title_hash") diff --git a/alembic/versions/20260226_0023_add_arxiv_runtime_state.py b/alembic/versions/20260226_0023_add_arxiv_runtime_state.py new file mode 100644 index 0000000..b9efd0d --- /dev/null +++ b/alembic/versions/20260226_0023_add_arxiv_runtime_state.py @@ -0,0 +1,55 @@ +"""Add shared arXiv runtime state table. + +Revision ID: 20260226_0023 +Revises: 20260225_0022 +Create Date: 2026-02-26 12:40:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20260226_0023" +down_revision: str | Sequence[str] | None = "20260225_0022" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + table_names = set(inspector.get_table_names()) + + if "arxiv_runtime_state" in table_names: + return + + op.create_table( + "arxiv_runtime_state", + sa.Column("state_key", sa.String(length=64), nullable=False), + sa.Column("next_allowed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cooldown_until", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.PrimaryKeyConstraint("state_key", name=op.f("pk_arxiv_runtime_state")), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + table_names = set(inspector.get_table_names()) + if "arxiv_runtime_state" in table_names: + op.drop_table("arxiv_runtime_state") diff --git a/alembic/versions/20260226_0024_add_arxiv_query_cache_entries.py b/alembic/versions/20260226_0024_add_arxiv_query_cache_entries.py new file mode 100644 index 0000000..8fddda4 --- /dev/null +++ b/alembic/versions/20260226_0024_add_arxiv_query_cache_entries.py @@ -0,0 +1,70 @@ +"""Add arXiv query cache table. + +Revision ID: 20260226_0024 +Revises: 20260226_0023 +Create Date: 2026-02-26 14:20:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20260226_0024" +down_revision: str | Sequence[str] | None = "20260226_0023" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + table_names = set(inspector.get_table_names()) + if "arxiv_query_cache_entries" in table_names: + return + + op.create_table( + "arxiv_query_cache_entries", + sa.Column("query_fingerprint", sa.String(length=64), nullable=False), + sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "cached_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.PrimaryKeyConstraint( + "query_fingerprint", + name=op.f("pk_arxiv_query_cache_entries"), + ), + ) + op.create_index( + "ix_arxiv_query_cache_expires_at", + "arxiv_query_cache_entries", + ["expires_at"], + unique=False, + ) + op.create_index( + "ix_arxiv_query_cache_cached_at", + "arxiv_query_cache_entries", + ["cached_at"], + unique=False, + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + table_names = set(inspector.get_table_names()) + if "arxiv_query_cache_entries" in table_names: + op.drop_table("arxiv_query_cache_entries") diff --git a/alembic/versions/44f7e10ef777_remove_doi_from_publications_and_.py b/alembic/versions/44f7e10ef777_remove_doi_from_publications_and_.py new file mode 100644 index 0000000..f47adbd --- /dev/null +++ b/alembic/versions/44f7e10ef777_remove_doi_from_publications_and_.py @@ -0,0 +1,27 @@ +"""remove_doi_from_publications_and_migrate_identifiers + +Revision ID: 44f7e10ef777 +Revises: 20260222_0016 +Create Date: 2026-02-22 17:03:56.261936 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '44f7e10ef777' +down_revision: str | Sequence[str] | None = '20260222_0016' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass + diff --git a/alembic/versions/fa0c5e3262d5_add_api_integration_keys_to_usersetting.py b/alembic/versions/fa0c5e3262d5_add_api_integration_keys_to_usersetting.py new file mode 100644 index 0000000..627a18a --- /dev/null +++ b/alembic/versions/fa0c5e3262d5_add_api_integration_keys_to_usersetting.py @@ -0,0 +1,39 @@ +"""Add API integration keys to UserSetting + +Revision ID: fa0c5e3262d5 +Revises: 44f7e10ef777 +Create Date: 2026-02-23 14:25:45.021512 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fa0c5e3262d5' +down_revision: str | Sequence[str] | None = '44f7e10ef777' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_publications_doi'), table_name='publications') + op.drop_column('publications', 'doi') + op.add_column('user_settings', sa.Column('openalex_api_key', sa.String(length=255), nullable=True)) + op.add_column('user_settings', sa.Column('crossref_api_token', sa.String(length=255), nullable=True)) + op.add_column('user_settings', sa.Column('crossref_api_mailto', sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user_settings', 'crossref_api_mailto') + op.drop_column('user_settings', 'crossref_api_token') + op.drop_column('user_settings', 'openalex_api_key') + op.add_column('publications', sa.Column('doi', sa.VARCHAR(length=255), autoincrement=False, nullable=True)) + op.create_index(op.f('ix_publications_doi'), 'publications', ['doi'], unique=False) + # ### end Alembic commands ### + diff --git a/app/api/__init__.py b/app/api/__init__.py index 3ce55a6..9d48db4 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -1,2 +1 @@ from __future__ import annotations - diff --git a/app/api/errors.py b/app/api/errors.py index 976ccb8..d926690 100644 --- a/app/api/errors.py +++ b/app/api/errors.py @@ -82,4 +82,3 @@ def register_api_exception_handlers(app: FastAPI) -> None: message="Request validation failed.", details=exc.errors(), ) - diff --git a/app/api/media.py b/app/api/media.py index 00943bd..3bed5f8 100644 --- a/app/api/media.py +++ b/app/api/media.py @@ -10,7 +10,8 @@ from app.api.deps import get_api_current_user from app.api.errors import ApiException from app.db.models import User from app.db.session import get_db_session -from app.services.domains.scholars import application as scholar_service +from app.services.scholars import application as scholar_service +from app.services.scholars import uploads as scholar_uploads from app.settings import settings router = APIRouter(tags=["media"]) @@ -41,7 +42,7 @@ async def get_uploaded_scholar_image( ) try: - image_path = scholar_service.resolve_upload_file_path( + image_path = scholar_uploads.resolve_upload_file_path( upload_dir=settings.scholar_image_upload_dir, relative_path=profile.profile_image_upload_path, ) diff --git a/app/api/router.py b/app/api/router.py index a11d98a..1b97d07 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -2,11 +2,12 @@ from __future__ import annotations from fastapi import APIRouter -from app.api.routers import admin, admin_dbops, auth, publications, runs, scholars, settings +from app.api.routers import admin, admin_dbops, admin_settings, auth, publications, runs, scholars, settings router = APIRouter(prefix="/api/v1") router.include_router(auth.router) router.include_router(admin.router) +router.include_router(admin_settings.router) router.include_router(admin_dbops.router) router.include_router(scholars.router) router.include_router(settings.router) diff --git a/app/api/routers/__init__.py b/app/api/routers/__init__.py index 3ce55a6..9d48db4 100644 --- a/app/api/routers/__init__.py +++ b/app/api/routers/__init__.py @@ -1,2 +1 @@ from __future__ import annotations - diff --git a/app/api/routers/admin.py b/app/api/routers/admin.py index 9844c3e..35c61b6 100644 --- a/app/api/routers/admin.py +++ b/app/api/routers/admin.py @@ -20,7 +20,8 @@ from app.auth.deps import get_auth_service from app.auth.service import AuthService from app.db.models import User from app.db.session import get_db_session -from app.services.domains.users import application as user_service +from app.logging_utils import structured_log +from app.services.users import application as user_service logger = logging.getLogger(__name__) @@ -48,14 +49,7 @@ async def list_users( admin_user: User = Depends(get_api_admin_user), ): users = await user_service.list_users(db_session) - logger.info( - "api.admin.users_listed", - extra={ - "event": "api.admin.users_listed", - "admin_user_id": int(admin_user.id), - "user_count": len(users), - }, - ) + structured_log(logger, "info", "api.admin.users_listed", admin_user_id=int(admin_user.id), user_count=len(users)) return success_payload( request, data={ @@ -92,14 +86,13 @@ async def create_user( message=str(exc), ) from exc - logger.info( + structured_log( + logger, + "info", "api.admin.user_created", - extra={ - "event": "api.admin.user_created", - "admin_user_id": int(admin_user.id), - "target_user_id": int(created_user.id), - "target_is_admin": bool(created_user.is_admin), - }, + admin_user_id=int(admin_user.id), + target_user_id=int(created_user.id), + target_is_admin=bool(created_user.is_admin), ) return success_payload( request, @@ -125,11 +118,7 @@ async def set_user_active( code="user_not_found", message="User not found.", ) - if ( - int(target_user.id) == int(admin_user.id) - and bool(target_user.is_active) - and not bool(payload.is_active) - ): + if int(target_user.id) == int(admin_user.id) and bool(target_user.is_active) and not bool(payload.is_active): raise ApiException( status_code=400, code="cannot_deactivate_self", @@ -140,14 +129,13 @@ async def set_user_active( user=target_user, is_active=bool(payload.is_active), ) - logger.info( + structured_log( + logger, + "info", "api.admin.user_active_updated", - extra={ - "event": "api.admin.user_active_updated", - "admin_user_id": int(admin_user.id), - "target_user_id": int(updated_user.id), - "is_active": bool(updated_user.is_active), - }, + admin_user_id=int(admin_user.id), + target_user_id=int(updated_user.id), + is_active=bool(updated_user.is_active), ) return success_payload( request, @@ -188,13 +176,12 @@ async def reset_user_password( user=target_user, password_hash=auth_service.hash_password(validated_password), ) - logger.info( + structured_log( + logger, + "info", "api.admin.user_password_reset", - extra={ - "event": "api.admin.user_password_reset", - "admin_user_id": int(admin_user.id), - "target_user_id": int(target_user.id), - }, + admin_user_id=int(admin_user.id), + target_user_id=int(target_user.id), ) return success_payload( request, diff --git a/app/api/routers/admin_dbops.py b/app/api/routers/admin_dbops.py index 16271a0..0f4d1f6 100644 --- a/app/api/routers/admin_dbops.py +++ b/app/api/routers/admin_dbops.py @@ -10,21 +10,25 @@ from app.api.errors import ApiException from app.api.responses import success_payload from app.api.schemas import ( AdminDbIntegrityEnvelope, - AdminPdfQueueBulkEnqueueEnvelope, - AdminPdfQueueRequeueEnvelope, - AdminPdfQueueEnvelope, AdminDbRepairJobsEnvelope, + AdminPdfQueueBulkEnqueueEnvelope, + AdminPdfQueueEnvelope, + AdminPdfQueueRequeueEnvelope, AdminRepairPublicationLinksEnvelope, AdminRepairPublicationLinksRequest, + AdminRepairPublicationNearDuplicatesEnvelope, + AdminRepairPublicationNearDuplicatesRequest, ) from app.db.models import DataRepairJob, User from app.db.session import get_db_session -from app.services.domains.dbops import ( +from app.logging_utils import structured_log +from app.services.dbops import ( collect_integrity_report, list_repair_jobs, run_publication_link_repair, + run_publication_near_duplicate_repair, ) -from app.services.domains.publications import application as publication_service +from app.services.publications import application as publication_service logger = logging.getLogger(__name__) @@ -48,7 +52,7 @@ def _serialize_repair_job(job: DataRepairJob) -> dict[str, object]: } -def _requested_by_value(*, payload: AdminRepairPublicationLinksRequest, admin_user: User) -> str: +def _requested_by_value(*, payload, admin_user: User) -> str: from_payload = (payload.requested_by or "").strip() return from_payload or admin_user.email @@ -57,7 +61,7 @@ def _serialize_pdf_queue_item(item) -> dict[str, object]: return { "publication_id": item.publication_id, "title": item.title, - "doi": item.doi, + "display_identifier": _serialize_display_identifier(item.display_identifier), "pdf_url": item.pdf_url, "status": item.status, "attempt_count": item.attempt_count, @@ -73,6 +77,18 @@ def _serialize_pdf_queue_item(item) -> dict[str, object]: } +def _serialize_display_identifier(value) -> dict[str, object] | None: + if value is None: + return None + return { + "kind": value.kind, + "value": value.value, + "label": value.label, + "url": value.url, + "confidence_score": float(value.confidence_score), + } + + def _requeue_response_state(*, queued: bool) -> tuple[str, str]: if queued: return "queued", "PDF lookup queued." @@ -120,15 +136,14 @@ async def get_integrity_report( admin_user: User = Depends(get_api_admin_user), ): report = await collect_integrity_report(db_session) - logger.info( + structured_log( + logger, + "info", "api.admin.db.integrity_checked", - extra={ - "event": "api.admin.db.integrity_checked", - "admin_user_id": int(admin_user.id), - "status": report.get("status"), - "failure_count": len(report.get("failures", [])), - "warning_count": len(report.get("warnings", [])), - }, + admin_user_id=int(admin_user.id), + status=report.get("status"), + failure_count=len(report.get("failures", [])), + warning_count=len(report.get("warnings", [])), ) return success_payload(request, data=report) @@ -144,14 +159,13 @@ async def get_repair_jobs( admin_user: User = Depends(get_api_admin_user), ): jobs = await list_repair_jobs(db_session, limit=limit) - logger.info( + structured_log( + logger, + "info", "api.admin.db.repair_jobs_listed", - extra={ - "event": "api.admin.db.repair_jobs_listed", - "admin_user_id": int(admin_user.id), - "limit": int(limit), - "job_count": len(jobs), - }, + admin_user_id=int(admin_user.id), + limit=int(limit), + job_count=len(jobs), ) return success_payload( request, @@ -186,18 +200,17 @@ async def get_pdf_queue( offset=resolved_offset, status=normalized_status, ) - logger.info( + structured_log( + logger, + "info", "api.admin.db.pdf_queue_listed", - extra={ - "event": "api.admin.db.pdf_queue_listed", - "admin_user_id": int(admin_user.id), - "page": int(resolved_page), - "page_size": int(resolved_limit), - "offset": int(resolved_offset), - "status": normalized_status, - "item_count": len(queue_page.items), - "total_count": int(queue_page.total_count), - }, + admin_user_id=int(admin_user.id), + page=int(resolved_page), + page_size=int(resolved_limit), + offset=int(resolved_offset), + status=normalized_status, + item_count=len(queue_page.items), + total_count=int(queue_page.total_count), ) return success_payload( request, @@ -236,14 +249,13 @@ async def requeue_pdf_lookup( message="Publication not found.", ) status, message = _requeue_response_state(queued=result.queued) - logger.info( - "api.admin.db.pdf_queue_requeue_requested", - extra={ - "event": "api.admin.db.pdf_queue_requeue_requested", - "admin_user_id": int(admin_user.id), - "publication_id": int(publication_id), - "queued": bool(result.queued), - }, + structured_log( + logger, + "info", + "api.admin.db.pdf_requeued", + admin_user_id=int(admin_user.id), + publication_id=int(publication_id), + queued=bool(result.queued), ) return success_payload( request, @@ -272,15 +284,14 @@ async def requeue_all_missing_pdfs( request_email=admin_user.email, limit=limit, ) - logger.info( + structured_log( + logger, + "info", "api.admin.db.pdf_queue_requeue_all", - extra={ - "event": "api.admin.db.pdf_queue_requeue_all", - "admin_user_id": int(admin_user.id), - "limit": int(limit), - "requested_count": int(result.requested_count), - "queued_count": int(result.queued_count), - }, + admin_user_id=int(admin_user.id), + limit=int(limit), + requested_count=int(result.requested_count), + queued_count=int(result.queued_count), ) return success_payload( request, @@ -321,17 +332,114 @@ async def trigger_publication_link_repair( code="invalid_repair_scope", message=str(exc), ) from exc - logger.info( + structured_log( + logger, + "info", "api.admin.db.publication_link_repair_triggered", - extra={ - "event": "api.admin.db.publication_link_repair_triggered", - "admin_user_id": int(admin_user.id), - "scope_mode": payload.scope_mode, - "target_user_id": int(payload.user_id) if payload.user_id is not None else None, - "dry_run": bool(payload.dry_run), - "gc_orphan_publications": bool(payload.gc_orphan_publications), - "job_id": int(result["job_id"]), - "status": result["status"], - }, + admin_user_id=int(admin_user.id), + scope_mode=payload.scope_mode, + target_user_id=int(payload.user_id) if payload.user_id is not None else None, + dry_run=bool(payload.dry_run), + gc_orphan_publications=bool(payload.gc_orphan_publications), + job_id=int(result["job_id"]), + status=result["status"], ) return success_payload(request, data=result) + + +@router.post( + "/repairs/publication-near-duplicates", + response_model=AdminRepairPublicationNearDuplicatesEnvelope, +) +async def trigger_publication_near_duplicate_repair( + payload: AdminRepairPublicationNearDuplicatesRequest, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + try: + result = await run_publication_near_duplicate_repair( + db_session, + dry_run=bool(payload.dry_run), + similarity_threshold=float(payload.similarity_threshold), + min_shared_tokens=int(payload.min_shared_tokens), + max_year_delta=int(payload.max_year_delta), + max_clusters=int(payload.max_clusters), + selected_cluster_keys=list(payload.selected_cluster_keys), + requested_by=_requested_by_value(payload=payload, admin_user=admin_user), + ) + except ValueError as exc: + raise ApiException( + status_code=400, + code="invalid_near_duplicate_repair_request", + message=str(exc), + ) from exc + structured_log( + logger, + "info", + "api.admin.db.dedup_repair_triggered", + admin_user_id=int(admin_user.id), + dry_run=bool(payload.dry_run), + selected_cluster_count=len(payload.selected_cluster_keys), + job_id=int(result["job_id"]), + status=result["status"], + ) + return success_payload(request, data=result) + + +DROP_PUBLICATIONS_CONFIRMATION = "DROP ALL PUBLICATIONS" + + +@router.post("/drop-all-publications") +async def drop_all_publications( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + body = await request.json() + confirmation_text = (body.get("confirmation_text") or "").strip() + if confirmation_text != DROP_PUBLICATIONS_CONFIRMATION: + raise ApiException( + status_code=400, + code="confirmation_required", + message=f"Type '{DROP_PUBLICATIONS_CONFIRMATION}' to confirm this destructive action.", + ) + + from sqlalchemy import delete, func, select, update + + from app.db.models import ( + Publication, + PublicationIdentifier, + PublicationPdfJob, + PublicationPdfJobEvent, + ScholarProfile, + ScholarPublication, + ) + + count_result = await db_session.execute(select(func.count()).select_from(Publication)) + total_publications = count_result.scalar_one() + + await db_session.execute(delete(ScholarPublication)) + await db_session.execute(delete(PublicationIdentifier)) + await db_session.execute(delete(PublicationPdfJobEvent)) + await db_session.execute(delete(PublicationPdfJob)) + await db_session.execute(delete(Publication)) + await db_session.execute(update(ScholarProfile).values(baseline_completed=False)) + await db_session.commit() + + structured_log( + logger, + "warning", + "api.admin.db.all_publications_dropped", + admin_user_id=int(admin_user.id), + admin_email=admin_user.email, + deleted_count=int(total_publications), + ) + return success_payload( + request, + data={ + "deleted_count": int(total_publications), + "message": f"Dropped {total_publications} publication(s) and all related data. " + "Scholar baselines have been reset; the next run will re-discover all publications.", + }, + ) diff --git a/app/api/routers/admin_settings.py b/app/api/routers/admin_settings.py new file mode 100644 index 0000000..c909874 --- /dev/null +++ b/app/api/routers/admin_settings.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, Request + +from app.api.deps import get_api_admin_user +from app.api.errors import ApiException +from app.api.responses import success_payload +from app.api.schemas import ( + AdminScholarHttpSettingsEnvelope, + AdminScholarHttpSettingsUpdateRequest, +) +from app.db.models import User +from app.logging_utils import structured_log +from app.settings import settings + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/settings", tags=["api-admin-settings"]) +_MAX_USER_AGENT_LENGTH = 512 +_MAX_ACCEPT_LANGUAGE_LENGTH = 128 +_MAX_COOKIE_LENGTH = 8192 + + +def _normalize_header_value(value: str, *, field_name: str, max_length: int) -> str: + normalized = value.strip() + if len(normalized) > max_length: + raise ApiException( + status_code=400, + code="invalid_admin_setting", + message=f"{field_name} must be {max_length} characters or fewer.", + ) + return normalized + + +def _serialize_scholar_http_settings() -> dict[str, object]: + return { + "user_agent": settings.scholar_http_user_agent, + "rotate_user_agent": bool(settings.scholar_http_rotate_user_agent), + "accept_language": settings.scholar_http_accept_language, + "cookie": settings.scholar_http_cookie, + } + + +def _apply_scholar_http_settings(payload: AdminScholarHttpSettingsUpdateRequest) -> None: + user_agent = _normalize_header_value( + payload.user_agent, + field_name="user_agent", + max_length=_MAX_USER_AGENT_LENGTH, + ) + accept_language = _normalize_header_value( + payload.accept_language, + field_name="accept_language", + max_length=_MAX_ACCEPT_LANGUAGE_LENGTH, + ) + cookie = _normalize_header_value( + payload.cookie, + field_name="cookie", + max_length=_MAX_COOKIE_LENGTH, + ) + object.__setattr__(settings, "scholar_http_user_agent", user_agent) + object.__setattr__(settings, "scholar_http_rotate_user_agent", bool(payload.rotate_user_agent)) + object.__setattr__(settings, "scholar_http_accept_language", accept_language) + object.__setattr__(settings, "scholar_http_cookie", cookie) + + +@router.get( + "/scholar-http", + response_model=AdminScholarHttpSettingsEnvelope, +) +async def get_scholar_http_settings( + request: Request, + admin_user: User = Depends(get_api_admin_user), +): + structured_log( + logger, + "info", + "api.admin.settings.scholar_http_read", + admin_user_id=int(admin_user.id), + ) + return success_payload(request, data=_serialize_scholar_http_settings()) + + +@router.put( + "/scholar-http", + response_model=AdminScholarHttpSettingsEnvelope, +) +async def update_scholar_http_settings( + payload: AdminScholarHttpSettingsUpdateRequest, + request: Request, + admin_user: User = Depends(get_api_admin_user), +): + _apply_scholar_http_settings(payload) + structured_log( + logger, + "info", + "api.admin.settings.scholar_http_updated", + admin_user_id=int(admin_user.id), + rotate_user_agent=bool(settings.scholar_http_rotate_user_agent), + user_agent_set=bool(settings.scholar_http_user_agent.strip()), + cookie_set=bool(settings.scholar_http_cookie.strip()), + ) + return success_payload(request, data=_serialize_scholar_http_settings()) diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 8f8da17..a46d71b 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -1,12 +1,13 @@ from __future__ import annotations import logging +from typing import NoReturn from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession -from app.api.errors import ApiException from app.api.deps import get_api_current_user +from app.api.errors import ApiException from app.api.responses import success_payload from app.api.schemas import ( AuthMeEnvelope, @@ -16,15 +17,16 @@ from app.api.schemas import ( LoginRequest, MessageEnvelope, ) +from app.auth import runtime as auth_runtime from app.auth.deps import get_auth_service, get_login_rate_limiter from app.auth.rate_limit import SlidingWindowRateLimiter -from app.auth import runtime as auth_runtime from app.auth.service import AuthService from app.auth.session import set_session_user from app.db.models import User from app.db.session import get_db_session +from app.logging_utils import structured_log from app.security.csrf import ensure_csrf_token -from app.services.domains.users import application as user_service +from app.services.users import application as user_service logger = logging.getLogger(__name__) @@ -36,13 +38,12 @@ def _login_limiter_key_and_email(request: Request, payload: LoginRequest) -> tup def _raise_rate_limited(normalized_email: str, retry_after_seconds: int) -> None: - logger.warning( + structured_log( + logger, + "warning", "api.auth.login_rate_limited", - extra={ - "event": "api.auth.login_rate_limited", - "email": normalized_email, - "retry_after_seconds": retry_after_seconds, - }, + email=normalized_email, + retry_after_seconds=retry_after_seconds, ) raise ApiException( status_code=429, @@ -61,11 +62,8 @@ def _serialize_user_payload(user: User) -> dict[str, object]: } -def _raise_invalid_credentials(*, normalized_email: str) -> None: - logger.info( - "api.auth.login_failed", - extra={"event": "api.auth.login_failed", "email": normalized_email}, - ) +def _raise_invalid_credentials(*, normalized_email: str) -> NoReturn: + structured_log(logger, "info", "api.auth.login_failed", email=normalized_email) raise ApiException( status_code=401, code="invalid_credentials", @@ -105,14 +103,7 @@ async def login( email=user.email, is_admin=user.is_admin, ) - logger.info( - "api.auth.login_succeeded", - extra={ - "event": "api.auth.login_succeeded", - "user_id": user.id, - "is_admin": user.is_admin, - }, - ) + structured_log(logger, "info", "api.auth.login_succeeded", user_id=user.id, is_admin=user.is_admin) return success_payload( request, data={ @@ -199,13 +190,7 @@ async def change_password( user=current_user, password_hash=auth_service.hash_password(validated_password), ) - logger.info( - "api.auth.password_changed", - extra={ - "event": "api.auth.password_changed", - "user_id": int(current_user.id), - }, - ) + structured_log(logger, "info", "api.auth.password_changed", user_id=int(current_user.id)) return success_payload( request, data={"message": "Password updated successfully."}, @@ -222,12 +207,8 @@ async def logout( ): current_user = await auth_runtime.get_authenticated_user(request, db_session) auth_runtime.invalidate_session(request) - logger.info( - "api.auth.logout", - extra={ - "event": "api.auth.logout", - "user_id": int(current_user.id) if current_user is not None else None, - }, + structured_log( + logger, "info", "api.auth.logout", user_id=int(current_user.id) if current_user is not None else None ) return success_payload( request, diff --git a/app/api/routers/publications.py b/app/api/routers/publications.py index 5055c6c..1d8ec53 100644 --- a/app/api/routers/publications.py +++ b/app/api/routers/publications.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from datetime import UTC, datetime from typing import Literal from fastapi import APIRouter, Depends, Path, Query, Request @@ -21,8 +22,10 @@ from app.api.schemas import ( ) from app.db.models import User from app.db.session import get_db_session -from app.services.domains.publications import application as publication_service -from app.services.domains.scholars import application as scholar_service +from app.logging_utils import structured_log +from app.services.publication_identifiers import application as identifier_service +from app.services.publications import application as publication_service +from app.services.scholars import application as scholar_service from app.settings import settings logger = logging.getLogger(__name__) @@ -61,7 +64,7 @@ def _serialize_publication_item(item) -> dict[str, object]: "citation_count": item.citation_count, "venue_text": item.venue_text, "pub_url": item.pub_url, - "doi": item.doi, + "display_identifier": _serialize_display_identifier(item.display_identifier), "pdf_url": item.pdf_url, "pdf_status": item.pdf_status, "pdf_attempt_count": item.pdf_attempt_count, @@ -74,29 +77,46 @@ def _serialize_publication_item(item) -> dict[str, object]: } +def _serialize_display_identifier(value) -> dict[str, object] | None: + if value is None: + return None + return { + "kind": value.kind, + "value": value.value, + "label": value.label, + "url": value.url, + "confidence_score": float(value.confidence_score), + } + + async def _publication_counts( db_session: AsyncSession, *, user_id: int, selected_scholar_id: int | None, favorite_only: bool, + search: str | None, + snapshot_before: datetime | None, ) -> tuple[int, int, int, int]: unread_count = await publication_service.count_unread_for_user( db_session, user_id=user_id, scholar_profile_id=selected_scholar_id, favorite_only=favorite_only, + snapshot_before=snapshot_before, ) favorites_count = await publication_service.count_favorite_for_user( db_session, user_id=user_id, scholar_profile_id=selected_scholar_id, + snapshot_before=snapshot_before, ) latest_count = await publication_service.count_latest_for_user( db_session, user_id=user_id, scholar_profile_id=selected_scholar_id, favorite_only=favorite_only, + snapshot_before=snapshot_before, ) total_count = await publication_service.count_for_user( db_session, @@ -104,6 +124,8 @@ async def _publication_counts( mode=publication_service.MODE_ALL, scholar_profile_id=selected_scholar_id, favorite_only=favorite_only, + search=search, + snapshot_before=snapshot_before, ) return unread_count, favorites_count, latest_count, total_count @@ -115,8 +137,12 @@ async def _list_publications_for_request( mode: Literal["all", "unread", "latest", "new"] | None, favorite_only: bool, scholar_profile_id: int | None, + search: str | None, + sort_by: str, + sort_dir: str, limit: int, offset: int, + snapshot_before: datetime | None, ) -> tuple[str, int | None, list]: resolved_mode = publication_service.resolve_publication_view_mode(mode) selected_scholar_id = scholar_profile_id @@ -131,8 +157,12 @@ async def _list_publications_for_request( mode=resolved_mode, scholar_profile_id=selected_scholar_id, favorite_only=favorite_only, + search=search, + sort_by=sort_by, + sort_dir=sort_dir, limit=limit, offset=offset, + snapshot_before=snapshot_before, ) await publication_service.schedule_missing_pdf_enrichment_for_user( db_session, @@ -148,6 +178,27 @@ async def _list_publications_for_request( return resolved_mode, selected_scholar_id, hydrated +def _resolve_publications_snapshot( + *, + snapshot: str | None, +) -> tuple[datetime, str]: + if snapshot is None: + now_utc = datetime.now(UTC) + return now_utc, now_utc.isoformat() + try: + parsed = datetime.fromisoformat(snapshot) + except ValueError as exc: + raise ApiException( + status_code=400, + code="invalid_snapshot", + message="Invalid publications snapshot cursor.", + ) from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + normalized = parsed.astimezone(UTC) + return normalized, normalized.isoformat() + + def _resolve_publications_paging( *, page: int, @@ -174,6 +225,7 @@ def _publications_list_data( page: int, page_size: int, offset: int, + snapshot: str, ) -> dict[str, object]: return { "mode": mode, @@ -186,6 +238,7 @@ def _publications_list_data( "total_count": total_count, "page": int(page), "page_size": int(page_size), + "snapshot": snapshot, "has_prev": int(offset) > 0, "has_next": int(offset) + int(page_size) < int(total_count), "publications": [_serialize_publication_item(item) for item in publications], @@ -280,32 +333,12 @@ async def _favorite_publication_state( db_session, items=[publication], ) - return hydrated[0] if hydrated else publication - - -def _log_retry_pdf_result( - *, - current_user: User, - scholar_profile_id: int, - publication_id: int, - queued: bool, - resolved_pdf: bool, - pdf_status: str, - doi: str | None, -) -> None: - logger.info( - "api.publications.retry_pdf", - extra={ - "event": "api.publications.retry_pdf", - "user_id": current_user.id, - "scholar_profile_id": scholar_profile_id, - "publication_id": publication_id, - "queued": queued, - "resolved_pdf": resolved_pdf, - "pdf_status": pdf_status, - "has_doi": bool(doi), - }, + current = hydrated[0] if hydrated else publication + identifiers = await identifier_service.overlay_publication_items_with_display_identifiers( + db_session, + items=[current], ) + return identifiers[0] if identifiers else current @router.get( @@ -317,10 +350,14 @@ async def list_publications( mode: Literal["all", "unread", "latest", "new"] | None = Query(default=None), favorite_only: bool = Query(default=False), scholar_profile_id: int | None = Query(default=None, ge=1), + search: str | None = Query(default=None, min_length=1, max_length=200), + sort_by: Literal["first_seen", "title", "year", "citations", "scholar", "pdf_status"] = Query(default="first_seen"), + sort_dir: Literal["asc", "desc"] = Query(default="desc"), page: int = Query(default=1, ge=1), page_size: int = Query(default=100, ge=1, le=500), limit: int | None = Query(default=None, ge=1, le=1000), offset: int | None = Query(default=None, ge=0), + snapshot: str | None = Query(default=None, min_length=1, max_length=64), db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): @@ -330,20 +367,28 @@ async def list_publications( limit=limit, offset=offset, ) + snapshot_before, snapshot_cursor = _resolve_publications_snapshot(snapshot=snapshot) + normalized_search = (search or "").strip() or None resolved_mode, selected_scholar_id, publications = await _list_publications_for_request( db_session, current_user=current_user, mode=mode, favorite_only=favorite_only, scholar_profile_id=scholar_profile_id, + search=normalized_search, + sort_by=sort_by, + sort_dir=sort_dir, limit=resolved_limit, offset=resolved_offset, + snapshot_before=snapshot_before, ) unread_count, favorites_count, latest_count, total_count = await _publication_counts( db_session, user_id=current_user.id, selected_scholar_id=selected_scholar_id, favorite_only=favorite_only, + search=normalized_search, + snapshot_before=snapshot_before, ) data = _publications_list_data( mode=resolved_mode, @@ -357,6 +402,7 @@ async def list_publications( page=resolved_page, page_size=resolved_limit, offset=resolved_offset, + snapshot=snapshot_cursor, ) return success_payload(request, data=data) @@ -374,13 +420,8 @@ async def mark_all_publications_read( db_session, user_id=current_user.id, ) - logger.info( - "api.publications.mark_all_read", - extra={ - "event": "api.publications.mark_all_read", - "user_id": current_user.id, - "updated_count": updated_count, - }, + structured_log( + logger, "info", "api.publications.mark_all_read", user_id=current_user.id, updated_count=updated_count ) return success_payload( request, @@ -401,25 +442,19 @@ async def mark_selected_publications_read( db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): - selection_pairs = sorted( - { - (int(item.scholar_profile_id), int(item.publication_id)) - for item in payload.selections - } - ) + selection_pairs = sorted({(int(item.scholar_profile_id), int(item.publication_id)) for item in payload.selections}) updated_count = await publication_service.mark_selected_as_read_for_user( db_session, user_id=current_user.id, selections=selection_pairs, ) - logger.info( + structured_log( + logger, + "info", "api.publications.mark_selected_read", - extra={ - "event": "api.publications.mark_selected_read", - "user_id": current_user.id, - "requested_count": len(selection_pairs), - "updated_count": updated_count, - }, + user_id=current_user.id, + requested_count=len(selection_pairs), + updated_count=updated_count, ) return success_payload( request, @@ -454,14 +489,16 @@ async def retry_publication_pdf( resolved_pdf=resolved_pdf, pdf_status=current.pdf_status, ) - _log_retry_pdf_result( - current_user=current_user, + structured_log( + logger, + "info", + "api.publications.retry_pdf", + user_id=current_user.id, scholar_profile_id=payload.scholar_profile_id, publication_id=publication_id, queued=queued, resolved_pdf=resolved_pdf, pdf_status=current.pdf_status, - doi=current.doi, ) return success_payload( request, @@ -492,15 +529,14 @@ async def toggle_publication_favorite( publication_id=publication_id, is_favorite=payload.is_favorite, ) - logger.info( + structured_log( + logger, + "info", "api.publications.favorite", - extra={ - "event": "api.publications.favorite", - "user_id": current_user.id, - "scholar_profile_id": payload.scholar_profile_id, - "publication_id": publication_id, - "is_favorite": bool(payload.is_favorite), - }, + user_id=current_user.id, + scholar_profile_id=payload.scholar_profile_id, + publication_id=publication_id, + is_favorite=bool(payload.is_favorite), ) return success_payload( request, diff --git a/app/api/routers/run_manual.py b/app/api/routers/run_manual.py new file mode 100644 index 0000000..cdb5e99 --- /dev/null +++ b/app/api/routers/run_manual.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import logging +from typing import Any, NoReturn + +from fastapi import Request +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.errors import ApiException +from app.api.responses import success_payload +from app.api.routers.run_serializers import manual_run_payload_from_run +from app.db.models import RunStatus, RunTriggerType +from app.logging_utils import structured_log +from app.services.ingestion import application as ingestion_service +from app.services.ingestion import safety as run_safety_service +from app.services.runs import application as run_service +from app.services.settings import application as user_settings_service +from app.settings import settings + +logger = logging.getLogger(__name__) + + +async def load_safety_state( + db_session: AsyncSession, + *, + user_id: int, +) -> dict[str, Any]: + user_settings = await user_settings_service.get_or_create_settings( + db_session, + user_id=user_id, + ) + previous_safety_state = run_safety_service.get_safety_event_context(user_settings) + if run_safety_service.clear_expired_cooldown(user_settings): + await db_session.commit() + await db_session.refresh(user_settings) + structured_log( + logger, + "info", + "api.runs.safety_cooldown_cleared", + user_id=user_id, + reason=previous_safety_state.get("cooldown_reason"), + cooldown_until=previous_safety_state.get("cooldown_until"), + ) + return run_safety_service.get_safety_state_payload(user_settings) + + +def raise_manual_runs_disabled(*, user_id: int, safety_state: dict[str, Any]) -> None: + structured_log( + logger, + "warning", + "api.runs.manual_blocked_policy", + user_id=user_id, + policy={"manual_run_allowed": False}, + safety_state=safety_state, + ) + raise ApiException( + status_code=403, + code="manual_runs_disabled", + message="Manual checks are disabled by server policy.", + details={ + "policy": {"manual_run_allowed": False}, + "safety_state": safety_state, + }, + ) + + +async def reused_manual_run_payload( + db_session: AsyncSession, + *, + request: Request, + user_id: int, + idempotency_key: str | None, + safety_state: dict[str, Any], +) -> dict[str, Any] | None: + if idempotency_key is None: + return None + previous_run = await run_service.get_manual_run_by_idempotency_key( + db_session, + user_id=user_id, + idempotency_key=idempotency_key, + ) + if previous_run is None: + return None + if previous_run.status in (RunStatus.RUNNING, RunStatus.RESOLVING): + raise ApiException( + status_code=409, + code="run_in_progress", + message="A run with this idempotency key is still in progress.", + details={"run_id": int(previous_run.id), "idempotency_key": idempotency_key}, + ) + return success_payload( + request, + data=manual_run_payload_from_run( + previous_run, + idempotency_key=idempotency_key, + reused_existing_run=True, + safety_state=safety_state, + ), + ) + + +async def run_ingestion_for_manual( + db_session: AsyncSession, + *, + ingest_service: ingestion_service.ScholarIngestionService, + user_id: int, + idempotency_key: str | None, +): + user_settings = await user_settings_service.get_or_create_settings( + db_session, + user_id=user_id, + ) + return await ingest_service.run_for_user( + db_session, + user_id=user_id, + trigger_type=RunTriggerType.MANUAL, + request_delay_seconds=user_settings.request_delay_seconds, + network_error_retries=settings.ingestion_network_error_retries, + retry_backoff_seconds=settings.ingestion_retry_backoff_seconds, + max_pages_per_scholar=settings.ingestion_max_pages_per_scholar, + page_size=settings.ingestion_page_size, + auto_queue_continuations=settings.ingestion_continuation_queue_enabled, + queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds, + idempotency_key=idempotency_key, + alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, + alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, + alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, + ) + + +async def recover_integrity_error( + db_session: AsyncSession, + *, + request: Request, + user_id: int, + idempotency_key: str | None, + original_exc: IntegrityError, +) -> dict[str, Any]: + if idempotency_key is None: + structured_log( + logger, + "exception", + "api.runs.manual_integrity_error", + user_id=user_id, + ) + raise ApiException(status_code=500, code="manual_run_failed", message="Manual run failed.") from original_exc + existing_run = await run_service.get_manual_run_by_idempotency_key( + db_session, + user_id=user_id, + idempotency_key=idempotency_key, + ) + if existing_run is None: + structured_log( + logger, + "exception", + "api.runs.manual_integrity_error", + user_id=user_id, + ) + raise ApiException(status_code=500, code="manual_run_failed", message="Manual run failed.") from original_exc + if existing_run.status in (RunStatus.RUNNING, RunStatus.RESOLVING): + raise ApiException( + status_code=409, + code="run_in_progress", + message="A run with this idempotency key is still in progress.", + details={"run_id": int(existing_run.id), "idempotency_key": idempotency_key}, + ) from original_exc + return success_payload( + request, + data=manual_run_payload_from_run( + existing_run, + idempotency_key=idempotency_key, + reused_existing_run=True, + safety_state=await load_safety_state(db_session, user_id=user_id), + ), + ) + + +async def execute_manual_run( + db_session: AsyncSession, + *, + request: Request, + ingest_service: ingestion_service.ScholarIngestionService, + user_id: int, + idempotency_key: str | None, +): + try: + return await run_ingestion_for_manual( + db_session, + ingest_service=ingest_service, + user_id=user_id, + idempotency_key=idempotency_key, + ) + except ingestion_service.RunAlreadyInProgressError as exc: + await db_session.rollback() + raise ApiException( + status_code=409, + code="run_in_progress", + message="A run is already in progress for this account.", + ) from exc + except ingestion_service.RunBlockedBySafetyPolicyError as exc: + await db_session.rollback() + raise_manual_blocked_safety(exc=exc, user_id=user_id) + except IntegrityError as exc: + await db_session.rollback() + return await recover_integrity_error( + db_session, + request=request, + user_id=user_id, + idempotency_key=idempotency_key, + original_exc=exc, + ) + except Exception as exc: + await db_session.rollback() + raise_manual_failed(exc=exc, user_id=user_id) + + +def raise_manual_blocked_safety(*, exc, user_id: int) -> NoReturn: + structured_log( + logger, + "info", + "api.runs.manual_blocked_safety", + user_id=user_id, + reason=exc.safety_state.get("cooldown_reason"), + cooldown_until=exc.safety_state.get("cooldown_until"), + cooldown_remaining_seconds=exc.safety_state.get("cooldown_remaining_seconds"), + ) + raise ApiException( + status_code=429, + code=exc.code, + message=exc.message, + details={"safety_state": exc.safety_state}, + ) from exc + + +def raise_manual_failed(*, exc: Exception, user_id: int) -> NoReturn: + structured_log( + logger, + "exception", + "api.runs.manual_failed", + user_id=user_id, + ) + raise ApiException(status_code=500, code="manual_run_failed", message="Manual run failed.") from exc diff --git a/app/api/routers/run_serializers.py b/app/api/routers/run_serializers.py new file mode 100644 index 0000000..53db3b5 --- /dev/null +++ b/app/api/routers/run_serializers.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import re +from typing import Any + +from app.api.errors import ApiException +from app.services.runs import application as run_service + +IDEMPOTENCY_HEADER = "Idempotency-Key" +IDEMPOTENCY_MAX_LENGTH = 128 +IDEMPOTENCY_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") + + +def _int_value(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _str_value(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text if text else None + + +def _bool_value(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return default + + +def normalize_idempotency_key(raw_value: str | None) -> str | None: + if raw_value is None: + return None + candidate = raw_value.strip() + if not candidate: + return None + if len(candidate) > IDEMPOTENCY_MAX_LENGTH or not IDEMPOTENCY_PATTERN.match(candidate): + raise ApiException( + status_code=400, + code="invalid_idempotency_key", + message=("Invalid Idempotency-Key. Use 8-128 characters from: A-Z a-z 0-9 . _ : -"), + ) + return candidate + + +def serialize_run(run) -> dict[str, Any]: + summary = run_service.extract_run_summary(run.error_log) + return { + "id": int(run.id), + "trigger_type": run.trigger_type.value, + "status": run.status.value, + "start_dt": run.start_dt, + "end_dt": run.end_dt, + "scholar_count": int(run.scholar_count or 0), + "new_publication_count": int(run.new_pub_count or 0), + "failed_count": int(summary["failed_count"]), + "partial_count": int(summary["partial_count"]), + } + + +def serialize_queue_item(item) -> dict[str, Any]: + return { + "id": int(item.id), + "scholar_profile_id": int(item.scholar_profile_id), + "scholar_label": item.scholar_label, + "status": item.status, + "reason": item.reason, + "dropped_reason": item.dropped_reason, + "attempt_count": int(item.attempt_count), + "resume_cstart": int(item.resume_cstart), + "next_attempt_dt": item.next_attempt_dt, + "updated_at": item.updated_at, + "last_error": item.last_error, + "last_run_id": item.last_run_id, + } + + +def _normalize_attempt_log(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, dict): + continue + normalized.append( + { + "attempt": _int_value(item.get("attempt"), 0), + "cstart": _int_value(item.get("cstart"), 0), + "state": _str_value(item.get("state")), + "state_reason": _str_value(item.get("state_reason")), + "status_code": (_int_value(item.get("status_code")) if item.get("status_code") is not None else None), + "fetch_error": _str_value(item.get("fetch_error")), + } + ) + return normalized + + +def _normalize_page_logs(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, dict): + continue + warning_codes = item.get("warning_codes") + normalized.append( + { + "page": _int_value(item.get("page"), 0), + "cstart": _int_value(item.get("cstart"), 0), + "state": _str_value(item.get("state")) or "unknown", + "state_reason": _str_value(item.get("state_reason")), + "status_code": (_int_value(item.get("status_code")) if item.get("status_code") is not None else None), + "publication_count": _int_value(item.get("publication_count"), 0), + "attempt_count": _int_value(item.get("attempt_count"), 0), + "has_show_more_button": _bool_value(item.get("has_show_more_button"), False), + "articles_range": _str_value(item.get("articles_range")), + "warning_codes": [str(code) for code in (warning_codes if isinstance(warning_codes, list) else [])], + } + ) + return normalized + + +def _normalize_debug(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict): + return None + marker_counts = value.get("marker_counts_nonzero") + warning_codes = value.get("warning_codes") + return { + "status_code": (_int_value(value.get("status_code")) if value.get("status_code") is not None else None), + "final_url": _str_value(value.get("final_url")), + "fetch_error": _str_value(value.get("fetch_error")), + "body_sha256": _str_value(value.get("body_sha256")), + "body_length": (_int_value(value.get("body_length")) if value.get("body_length") is not None else None), + "has_show_more_button": ( + _bool_value(value.get("has_show_more_button"), False) + if value.get("has_show_more_button") is not None + else None + ), + "articles_range": _str_value(value.get("articles_range")), + "state_reason": _str_value(value.get("state_reason")), + "warning_codes": [str(code) for code in (warning_codes if isinstance(warning_codes, list) else [])], + "marker_counts_nonzero": { + str(key): _int_value(count, 0) + for key, count in (marker_counts.items() if isinstance(marker_counts, dict) else []) + }, + "page_logs": _normalize_page_logs(value.get("page_logs")), + "attempt_log": _normalize_attempt_log(value.get("attempt_log")), + } + + +def normalize_scholar_result(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return { + "scholar_profile_id": 0, + "scholar_id": "unknown", + "state": "unknown", + "state_reason": None, + "outcome": "failed", + "attempt_count": 0, + "publication_count": 0, + "start_cstart": 0, + "continuation_cstart": None, + "continuation_enqueued": False, + "continuation_cleared": False, + "warnings": [], + "error": None, + "debug": None, + } + warnings = value.get("warnings") + return { + "scholar_profile_id": _int_value(value.get("scholar_profile_id"), 0), + "scholar_id": _str_value(value.get("scholar_id")) or "unknown", + "state": _str_value(value.get("state")) or "unknown", + "state_reason": _str_value(value.get("state_reason")), + "outcome": _str_value(value.get("outcome")) or "failed", + "attempt_count": _int_value(value.get("attempt_count"), 0), + "publication_count": _int_value(value.get("publication_count"), 0), + "start_cstart": _int_value(value.get("start_cstart"), 0), + "continuation_cstart": ( + _int_value(value.get("continuation_cstart")) if value.get("continuation_cstart") is not None else None + ), + "continuation_enqueued": _bool_value(value.get("continuation_enqueued"), False), + "continuation_cleared": _bool_value(value.get("continuation_cleared"), False), + "warnings": [str(item) for item in (warnings if isinstance(warnings, list) else [])], + "error": _str_value(value.get("error")), + "debug": _normalize_debug(value.get("debug")), + } + + +def manual_run_payload_from_run( + run, + *, + idempotency_key: str | None, + reused_existing_run: bool, + safety_state: dict[str, Any], +) -> dict[str, Any]: + summary = run_service.extract_run_summary(run.error_log) + return { + "run_id": int(run.id), + "status": run.status.value, + "scholar_count": int(run.scholar_count or 0), + "succeeded_count": int(summary["succeeded_count"]), + "failed_count": int(summary["failed_count"]), + "partial_count": int(summary["partial_count"]), + "new_publication_count": int(run.new_pub_count or 0), + "reused_existing_run": reused_existing_run, + "idempotency_key": idempotency_key, + "safety_state": safety_state, + } + + +def manual_run_success_payload( + *, + run_summary, + idempotency_key: str | None, + safety_state: dict[str, Any], +) -> dict[str, Any]: + return { + "run_id": run_summary.crawl_run_id, + "status": run_summary.status.value, + "scholar_count": run_summary.scholar_count, + "succeeded_count": run_summary.succeeded_count, + "failed_count": run_summary.failed_count, + "partial_count": run_summary.partial_count, + "new_publication_count": run_summary.new_publication_count, + "reused_existing_run": False, + "idempotency_key": idempotency_key, + "safety_state": safety_state, + } diff --git a/app/api/routers/runs.py b/app/api/routers/runs.py index ddd5290..15d86f3 100644 --- a/app/api/routers/runs.py +++ b/app/api/routers/runs.py @@ -1,16 +1,33 @@ from __future__ import annotations +import asyncio import logging -import re from typing import Any from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import StreamingResponse from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_api_current_user from app.api.errors import ApiException from app.api.responses import success_payload +from app.api.routers.run_manual import ( + load_safety_state, + raise_manual_blocked_safety, + raise_manual_failed, + raise_manual_runs_disabled, + recover_integrity_error, + reused_manual_run_payload, +) +from app.api.routers.run_serializers import ( + IDEMPOTENCY_HEADER, + normalize_idempotency_key, + normalize_scholar_result, + serialize_queue_item, + serialize_run, +) +from app.api.runtime_deps import get_ingestion_service from app.api.schemas import ( ManualRunEnvelope, QueueClearEnvelope, @@ -20,500 +37,27 @@ from app.api.schemas import ( RunsListEnvelope, ) from app.db.models import RunStatus, RunTriggerType, User -from app.db.session import get_db_session -from app.services.domains.ingestion import application as ingestion_service -from app.services.domains.ingestion import safety as run_safety_service -from app.services.domains.runs import application as run_service -from app.services.domains.settings import application as user_settings_service +from app.db.session import get_db_session, get_session_factory +from app.logging_utils import structured_log +from app.services.ingestion import application as ingestion_service +from app.services.runs import application as run_service +from app.services.runs.events import event_generator +from app.services.settings import application as user_settings_service from app.settings import settings -from app.api.runtime_deps import get_ingestion_service logger = logging.getLogger(__name__) +_background_tasks: set[asyncio.Task[Any]] = set() + + +def _drop_finished_task(task: asyncio.Task[Any]) -> None: + _background_tasks.discard(task) + try: + task.result() + except Exception: + structured_log(logger, "exception", "runs.background_task_failed") router = APIRouter(prefix="/runs", tags=["api-runs"]) - -IDEMPOTENCY_HEADER = "Idempotency-Key" -IDEMPOTENCY_MAX_LENGTH = 128 -IDEMPOTENCY_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") - - -def _int_value(value: Any, default: int = 0) -> int: - try: - return int(value) - except (TypeError, ValueError): - return default - - -def _str_value(value: Any) -> str | None: - if value is None: - return None - text = str(value).strip() - return text if text else None - - -def _bool_value(value: Any, default: bool = False) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - lowered = value.strip().lower() - if lowered in {"1", "true", "yes", "on"}: - return True - if lowered in {"0", "false", "no", "off"}: - return False - return default - - -def _normalize_idempotency_key(raw_value: str | None) -> str | None: - if raw_value is None: - return None - candidate = raw_value.strip() - if not candidate: - return None - if len(candidate) > IDEMPOTENCY_MAX_LENGTH or not IDEMPOTENCY_PATTERN.match(candidate): - raise ApiException( - status_code=400, - code="invalid_idempotency_key", - message=( - "Invalid Idempotency-Key. Use 8-128 characters from: " - "A-Z a-z 0-9 . _ : -" - ), - ) - return candidate - - -def _serialize_run(run) -> dict[str, Any]: - summary = run_service.extract_run_summary(run.error_log) - return { - "id": int(run.id), - "trigger_type": run.trigger_type.value, - "status": run.status.value, - "start_dt": run.start_dt, - "end_dt": run.end_dt, - "scholar_count": int(run.scholar_count or 0), - "new_publication_count": int(run.new_pub_count or 0), - "failed_count": int(summary["failed_count"]), - "partial_count": int(summary["partial_count"]), - } - - -def _serialize_queue_item(item) -> dict[str, Any]: - return { - "id": int(item.id), - "scholar_profile_id": int(item.scholar_profile_id), - "scholar_label": item.scholar_label, - "status": item.status, - "reason": item.reason, - "dropped_reason": item.dropped_reason, - "attempt_count": int(item.attempt_count), - "resume_cstart": int(item.resume_cstart), - "next_attempt_dt": item.next_attempt_dt, - "updated_at": item.updated_at, - "last_error": item.last_error, - "last_run_id": item.last_run_id, - } - - -def _normalize_attempt_log(value: Any) -> list[dict[str, Any]]: - if not isinstance(value, list): - return [] - normalized: list[dict[str, Any]] = [] - for item in value: - if not isinstance(item, dict): - continue - normalized.append( - { - "attempt": _int_value(item.get("attempt"), 0), - "cstart": _int_value(item.get("cstart"), 0), - "state": _str_value(item.get("state")), - "state_reason": _str_value(item.get("state_reason")), - "status_code": ( - _int_value(item.get("status_code")) - if item.get("status_code") is not None - else None - ), - "fetch_error": _str_value(item.get("fetch_error")), - } - ) - return normalized - - -def _normalize_page_logs(value: Any) -> list[dict[str, Any]]: - if not isinstance(value, list): - return [] - normalized: list[dict[str, Any]] = [] - for item in value: - if not isinstance(item, dict): - continue - warning_codes = item.get("warning_codes") - normalized.append( - { - "page": _int_value(item.get("page"), 0), - "cstart": _int_value(item.get("cstart"), 0), - "state": _str_value(item.get("state")) or "unknown", - "state_reason": _str_value(item.get("state_reason")), - "status_code": ( - _int_value(item.get("status_code")) - if item.get("status_code") is not None - else None - ), - "publication_count": _int_value(item.get("publication_count"), 0), - "attempt_count": _int_value(item.get("attempt_count"), 0), - "has_show_more_button": _bool_value(item.get("has_show_more_button"), False), - "articles_range": _str_value(item.get("articles_range")), - "warning_codes": [ - str(code) - for code in (warning_codes if isinstance(warning_codes, list) else []) - ], - } - ) - return normalized - - -def _normalize_debug(value: Any) -> dict[str, Any] | None: - if not isinstance(value, dict): - return None - marker_counts = value.get("marker_counts_nonzero") - warning_codes = value.get("warning_codes") - return { - "status_code": ( - _int_value(value.get("status_code")) - if value.get("status_code") is not None - else None - ), - "final_url": _str_value(value.get("final_url")), - "fetch_error": _str_value(value.get("fetch_error")), - "body_sha256": _str_value(value.get("body_sha256")), - "body_length": ( - _int_value(value.get("body_length")) - if value.get("body_length") is not None - else None - ), - "has_show_more_button": ( - _bool_value(value.get("has_show_more_button"), False) - if value.get("has_show_more_button") is not None - else None - ), - "articles_range": _str_value(value.get("articles_range")), - "state_reason": _str_value(value.get("state_reason")), - "warning_codes": [ - str(code) - for code in (warning_codes if isinstance(warning_codes, list) else []) - ], - "marker_counts_nonzero": { - str(key): _int_value(count, 0) - for key, count in (marker_counts.items() if isinstance(marker_counts, dict) else []) - }, - "page_logs": _normalize_page_logs(value.get("page_logs")), - "attempt_log": _normalize_attempt_log(value.get("attempt_log")), - } - - -def _normalize_scholar_result(value: Any) -> dict[str, Any]: - if not isinstance(value, dict): - return { - "scholar_profile_id": 0, - "scholar_id": "unknown", - "state": "unknown", - "state_reason": None, - "outcome": "failed", - "attempt_count": 0, - "publication_count": 0, - "start_cstart": 0, - "continuation_cstart": None, - "continuation_enqueued": False, - "continuation_cleared": False, - "warnings": [], - "error": None, - "debug": None, - } - warnings = value.get("warnings") - return { - "scholar_profile_id": _int_value(value.get("scholar_profile_id"), 0), - "scholar_id": _str_value(value.get("scholar_id")) or "unknown", - "state": _str_value(value.get("state")) or "unknown", - "state_reason": _str_value(value.get("state_reason")), - "outcome": _str_value(value.get("outcome")) or "failed", - "attempt_count": _int_value(value.get("attempt_count"), 0), - "publication_count": _int_value(value.get("publication_count"), 0), - "start_cstart": _int_value(value.get("start_cstart"), 0), - "continuation_cstart": ( - _int_value(value.get("continuation_cstart")) - if value.get("continuation_cstart") is not None - else None - ), - "continuation_enqueued": _bool_value(value.get("continuation_enqueued"), False), - "continuation_cleared": _bool_value(value.get("continuation_cleared"), False), - "warnings": [str(item) for item in (warnings if isinstance(warnings, list) else [])], - "error": _str_value(value.get("error")), - "debug": _normalize_debug(value.get("debug")), - } - - -def _manual_run_payload_from_run( - run, - *, - idempotency_key: str | None, - reused_existing_run: bool, - safety_state: dict[str, Any], -) -> dict[str, Any]: - summary = run_service.extract_run_summary(run.error_log) - return { - "run_id": int(run.id), - "status": run.status.value, - "scholar_count": int(run.scholar_count or 0), - "succeeded_count": int(summary["succeeded_count"]), - "failed_count": int(summary["failed_count"]), - "partial_count": int(summary["partial_count"]), - "new_publication_count": int(run.new_pub_count or 0), - "reused_existing_run": reused_existing_run, - "idempotency_key": idempotency_key, - "safety_state": safety_state, - } - - -async def _load_safety_state( - db_session: AsyncSession, - *, - user_id: int, -) -> dict[str, Any]: - user_settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=user_id, - ) - previous_safety_state = run_safety_service.get_safety_event_context(user_settings) - if run_safety_service.clear_expired_cooldown(user_settings): - await db_session.commit() - await db_session.refresh(user_settings) - logger.info( - "api.runs.safety_cooldown_cleared", - extra={ - "event": "api.runs.safety_cooldown_cleared", - "user_id": user_id, - "reason": previous_safety_state.get("cooldown_reason"), - "cooldown_until": previous_safety_state.get("cooldown_until"), - "metric_name": "api_runs_safety_cooldown_cleared_total", - "metric_value": 1, - }, - ) - return run_safety_service.get_safety_state_payload(user_settings) - - -def _raise_manual_runs_disabled(*, user_id: int, safety_state: dict[str, Any]) -> None: - logger.warning( - "api.runs.manual_blocked_policy", - extra={ - "event": "api.runs.manual_blocked_policy", - "user_id": user_id, - "policy": {"manual_run_allowed": False}, - "safety_state": safety_state, - "metric_name": "api_runs_manual_blocked_policy_total", - "metric_value": 1, - }, - ) - raise ApiException( - status_code=403, - code="manual_runs_disabled", - message="Manual checks are disabled by server policy.", - details={ - "policy": {"manual_run_allowed": False}, - "safety_state": safety_state, - }, - ) - - -async def _reused_manual_run_payload( - db_session: AsyncSession, - *, - request: Request, - user_id: int, - idempotency_key: str | None, - safety_state: dict[str, Any], -) -> dict[str, Any] | None: - if idempotency_key is None: - return None - previous_run = await run_service.get_manual_run_by_idempotency_key( - db_session, - user_id=user_id, - idempotency_key=idempotency_key, - ) - if previous_run is None: - return None - if previous_run.status == RunStatus.RUNNING: - raise ApiException( - status_code=409, - code="run_in_progress", - message="A run with this idempotency key is still in progress.", - details={"run_id": int(previous_run.id), "idempotency_key": idempotency_key}, - ) - return success_payload( - request, - data=_manual_run_payload_from_run( - previous_run, - idempotency_key=idempotency_key, - reused_existing_run=True, - safety_state=safety_state, - ), - ) - - -async def _run_ingestion_for_manual( - db_session: AsyncSession, - *, - ingest_service: ingestion_service.ScholarIngestionService, - user_id: int, - idempotency_key: str | None, -): - user_settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=user_id, - ) - return await ingest_service.run_for_user( - db_session, - user_id=user_id, - trigger_type=RunTriggerType.MANUAL, - request_delay_seconds=user_settings.request_delay_seconds, - network_error_retries=settings.ingestion_network_error_retries, - retry_backoff_seconds=settings.ingestion_retry_backoff_seconds, - max_pages_per_scholar=settings.ingestion_max_pages_per_scholar, - page_size=settings.ingestion_page_size, - auto_queue_continuations=settings.ingestion_continuation_queue_enabled, - queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds, - idempotency_key=idempotency_key, - alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, - alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, - alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, - ) - - -async def _recover_integrity_error( - db_session: AsyncSession, - *, - request: Request, - user_id: int, - idempotency_key: str | None, - original_exc: IntegrityError, -) -> dict[str, Any]: - if idempotency_key is None: - logger.exception( - "api.runs.manual_integrity_error", - extra={"event": "api.runs.manual_integrity_error", "user_id": user_id}, - ) - raise ApiException(status_code=500, code="manual_run_failed", message="Manual run failed.") from original_exc - existing_run = await run_service.get_manual_run_by_idempotency_key( - db_session, - user_id=user_id, - idempotency_key=idempotency_key, - ) - if existing_run is None: - logger.exception( - "api.runs.manual_integrity_error", - extra={"event": "api.runs.manual_integrity_error", "user_id": user_id}, - ) - raise ApiException(status_code=500, code="manual_run_failed", message="Manual run failed.") from original_exc - if existing_run.status == RunStatus.RUNNING: - raise ApiException( - status_code=409, - code="run_in_progress", - message="A run with this idempotency key is still in progress.", - details={"run_id": int(existing_run.id), "idempotency_key": idempotency_key}, - ) from original_exc - return success_payload( - request, - data=_manual_run_payload_from_run( - existing_run, - idempotency_key=idempotency_key, - reused_existing_run=True, - safety_state=await _load_safety_state(db_session, user_id=user_id), - ), - ) - - -async def _execute_manual_run( - db_session: AsyncSession, - *, - request: Request, - ingest_service: ingestion_service.ScholarIngestionService, - user_id: int, - idempotency_key: str | None, -): - try: - return await _run_ingestion_for_manual( - db_session, - ingest_service=ingest_service, - user_id=user_id, - idempotency_key=idempotency_key, - ) - except ingestion_service.RunAlreadyInProgressError as exc: - await db_session.rollback() - raise ApiException( - status_code=409, - code="run_in_progress", - message="A run is already in progress for this account.", - ) from exc - except ingestion_service.RunBlockedBySafetyPolicyError as exc: - await db_session.rollback() - _raise_manual_blocked_safety(exc=exc, user_id=user_id) - except IntegrityError as exc: - await db_session.rollback() - return await _recover_integrity_error( - db_session, - request=request, - user_id=user_id, - idempotency_key=idempotency_key, - original_exc=exc, - ) - except Exception as exc: - await db_session.rollback() - _raise_manual_failed(exc=exc, user_id=user_id) - - -def _raise_manual_blocked_safety(*, exc, user_id: int) -> None: - logger.info( - "api.runs.manual_blocked_safety", - extra={ - "event": "api.runs.manual_blocked_safety", - "user_id": user_id, - "reason": exc.safety_state.get("cooldown_reason"), - "cooldown_until": exc.safety_state.get("cooldown_until"), - "cooldown_remaining_seconds": exc.safety_state.get("cooldown_remaining_seconds"), - "metric_name": "api_runs_manual_blocked_safety_total", - "metric_value": 1, - }, - ) - raise ApiException( - status_code=429, - code=exc.code, - message=exc.message, - details={"safety_state": exc.safety_state}, - ) from exc - - -def _raise_manual_failed(*, exc: Exception, user_id: int) -> None: - logger.exception( - "api.runs.manual_failed", - extra={"event": "api.runs.manual_failed", "user_id": user_id}, - ) - raise ApiException(status_code=500, code="manual_run_failed", message="Manual run failed.") from exc - - -def _manual_run_success_payload( - *, - run_summary, - idempotency_key: str | None, - safety_state: dict[str, Any], -) -> dict[str, Any]: - return { - "run_id": run_summary.crawl_run_id, - "status": run_summary.status.value, - "scholar_count": run_summary.scholar_count, - "succeeded_count": run_summary.succeeded_count, - "failed_count": run_summary.failed_count, - "partial_count": run_summary.partial_count, - "new_publication_count": run_summary.new_publication_count, - "reused_existing_run": False, - "idempotency_key": idempotency_key, - "safety_state": safety_state, - } +ACTIVE_RUN_STATUSES = {RunStatus.RUNNING, RunStatus.RESOLVING} @router.get( @@ -533,14 +77,14 @@ async def list_runs( limit=limit, failed_only=failed_only, ) - safety_state = await _load_safety_state( + safety_state = await load_safety_state( db_session, user_id=current_user.id, ) return success_payload( request, data={ - "runs": [_serialize_run(run) for run in runs], + "runs": [serialize_run(run) for run in runs], "safety_state": safety_state, }, ) @@ -571,21 +115,137 @@ async def get_run( scholar_results = error_log.get("scholar_results") if not isinstance(scholar_results, list): scholar_results = [] - safety_state = await _load_safety_state( + safety_state = await load_safety_state( db_session, user_id=current_user.id, ) return success_payload( request, data={ - "run": _serialize_run(run), + "run": serialize_run(run), "summary": run_service.extract_run_summary(error_log), - "scholar_results": [_normalize_scholar_result(item) for item in scholar_results], + "scholar_results": [normalize_scholar_result(item) for item in scholar_results], "safety_state": safety_state, }, ) +@router.post( + "/{run_id}/cancel", + response_model=RunDetailEnvelope, +) +async def cancel_run( + run_id: int, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + run = await run_service.get_run_for_user( + db_session, + user_id=current_user.id, + run_id=run_id, + ) + if run is None: + raise ApiException( + status_code=404, + code="run_not_found", + message="Run not found.", + ) + + if run.status in ACTIVE_RUN_STATUSES: + run.status = RunStatus.CANCELED + await db_session.commit() + await db_session.refresh(run) + else: + raise ApiException( + status_code=409, + code="run_not_cancelable", + message="Run is already terminal and cannot be canceled.", + details={"run_id": int(run.id), "status": run.status.value}, + ) + + error_log = run.error_log if isinstance(run.error_log, dict) else {} + scholar_results = error_log.get("scholar_results") + if not isinstance(scholar_results, list): + scholar_results = [] + + safety_state = await load_safety_state( + db_session, + user_id=current_user.id, + ) + + return success_payload( + request, + data={ + "run": serialize_run(run), + "summary": run_service.extract_run_summary(error_log), + "scholar_results": [normalize_scholar_result(item) for item in scholar_results], + "safety_state": safety_state, + }, + ) + + +async def _start_manual_run( + db_session: AsyncSession, + *, + current_user: User, + ingest_service: ingestion_service.ScholarIngestionService, + idempotency_key: str | None, +) -> tuple[Any, Any, list, dict]: + user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=current_user.id) + run, scholars, start_cstart_map = await ingest_service.initialize_run( + db_session, + user_id=current_user.id, + trigger_type=RunTriggerType.MANUAL, + request_delay_seconds=user_settings.request_delay_seconds, + network_error_retries=settings.ingestion_network_error_retries, + retry_backoff_seconds=settings.ingestion_retry_backoff_seconds, + max_pages_per_scholar=settings.ingestion_max_pages_per_scholar, + page_size=settings.ingestion_page_size, + idempotency_key=idempotency_key, + alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, + alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, + alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, + ) + return user_settings, run, scholars, start_cstart_map + + +def _spawn_background_execution( + ingest_service: ingestion_service.ScholarIngestionService, + *, + run: Any, + current_user: User, + scholars: list, + start_cstart_map: dict, + user_settings: Any, + idempotency_key: str | None, +) -> None: + from app.db.session import get_session_factory + + task = asyncio.create_task( + ingest_service.execute_run( + session_factory=get_session_factory(), + run_id=run.id, + user_id=current_user.id, + scholars=scholars, + start_cstart_map=start_cstart_map, + request_delay_seconds=user_settings.request_delay_seconds, + network_error_retries=settings.ingestion_network_error_retries, + retry_backoff_seconds=settings.ingestion_retry_backoff_seconds, + max_pages_per_scholar=settings.ingestion_max_pages_per_scholar, + page_size=settings.ingestion_page_size, + auto_queue_continuations=settings.ingestion_continuation_queue_enabled, + queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds, + alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, + alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, + alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, + idempotency_key=idempotency_key, + ) + ) + _background_tasks.add(task) + task.add_done_callback(_drop_finished_task) + + @router.post( "/manual", response_model=ManualRunEnvelope, @@ -596,46 +256,76 @@ async def run_manual( current_user: User = Depends(get_api_current_user), ingest_service: ingestion_service.ScholarIngestionService = Depends(get_ingestion_service), ): - safety_state = await _load_safety_state( - db_session, - user_id=current_user.id, - ) + user_id = int(current_user.id) + safety_state = await load_safety_state(db_session, user_id=user_id) if not settings.ingestion_manual_run_allowed: - _raise_manual_runs_disabled(user_id=current_user.id, safety_state=safety_state) + raise_manual_runs_disabled(user_id=user_id, safety_state=safety_state) - idempotency_key = _normalize_idempotency_key(request.headers.get(IDEMPOTENCY_HEADER)) - reused_payload = await _reused_manual_run_payload( + idempotency_key = normalize_idempotency_key(request.headers.get(IDEMPOTENCY_HEADER)) + reused_payload = await reused_manual_run_payload( db_session, request=request, - user_id=current_user.id, + user_id=user_id, idempotency_key=idempotency_key, safety_state=safety_state, ) if reused_payload is not None: return reused_payload - run_summary = await _execute_manual_run( - db_session, - request=request, - ingest_service=ingest_service, - user_id=current_user.id, - idempotency_key=idempotency_key, - ) - if isinstance(run_summary, dict): - return run_summary - - current_safety_state = await _load_safety_state( - db_session, - user_id=current_user.id, - ) - return success_payload( - request, - data=_manual_run_success_payload( - run_summary=run_summary, + try: + user_settings, run, scholars, start_cstart_map = await _start_manual_run( + db_session, + current_user=current_user, + ingest_service=ingest_service, idempotency_key=idempotency_key, - safety_state=current_safety_state, - ), - ) + ) + await db_session.commit() + _spawn_background_execution( + ingest_service, + run=run, + current_user=current_user, + scholars=scholars, + start_cstart_map=start_cstart_map, + user_settings=user_settings, + idempotency_key=idempotency_key, + ) + return success_payload( + request, + data={ + "run_id": int(run.id), + "status": run.status.value, + "scholar_count": int(run.scholar_count or 0), + "succeeded_count": 0, + "failed_count": 0, + "partial_count": 0, + "new_publication_count": 0, + "reused_existing_run": False, + "idempotency_key": idempotency_key, + "safety_state": await load_safety_state(db_session, user_id=user_id), + }, + ) + except ingestion_service.RunBlockedBySafetyPolicyError as exc: + await db_session.rollback() + raise_manual_blocked_safety(exc=exc, user_id=user_id) + except ingestion_service.RunAlreadyInProgressError as exc: + await db_session.rollback() + raise ApiException( + status_code=409, + code="run_in_progress", + message="A run is already in progress for this account.", + ) from exc + except IntegrityError as exc: + await db_session.rollback() + return await recover_integrity_error( + db_session, + request=request, + user_id=user_id, + idempotency_key=idempotency_key, + original_exc=exc, + ) + except Exception as exc: + await db_session.rollback() + raise_manual_failed(exc=exc, user_id=user_id) @router.get( @@ -656,7 +346,7 @@ async def list_queue_items( return success_payload( request, data={ - "queue_items": [_serialize_queue_item(item) for item in items], + "queue_items": [serialize_queue_item(item) for item in items], }, ) @@ -692,7 +382,7 @@ async def retry_queue_item( ) return success_payload( request, - data=_serialize_queue_item(queue_item), + data=serialize_queue_item(queue_item), ) @@ -727,7 +417,7 @@ async def drop_queue_item( ) return success_payload( request, - data=_serialize_queue_item(dropped), + data=serialize_queue_item(dropped), ) @@ -769,3 +459,24 @@ async def clear_queue_item( "message": "Queue item cleared.", }, ) + + +@router.get("/{run_id}/stream") +async def stream_run_events( + run_id: int, + current_user: User = Depends(get_api_current_user), +): + session_factory = get_session_factory() + async with session_factory() as db_session: + run = await run_service.get_run_for_user( + db_session, + user_id=current_user.id, + run_id=run_id, + ) + if run is None: + raise ApiException( + status_code=404, + code="run_not_found", + message="Run not found.", + ) + return StreamingResponse(event_generator(run_id), media_type="text/event-stream") diff --git a/app/api/routers/scholar_helpers.py b/app/api/routers/scholar_helpers.py new file mode 100644 index 0000000..4023a3a --- /dev/null +++ b/app/api/routers/scholar_helpers.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from fastapi import UploadFile +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.errors import ApiException +from app.logging_utils import structured_log +from app.services.ingestion import queue as ingestion_queue_service +from app.services.scholar import rate_limit as scholar_rate_limit +from app.services.scholar.source import ScholarSource +from app.services.scholars import application as scholar_service +from app.services.scholars import search_hints as scholar_search_hints +from app.settings import settings + +logger = logging.getLogger(__name__) + +CREATE_METADATA_HYDRATION_TIMEOUT_SECONDS = 5.0 +INITIAL_SCHOLAR_SCRAPE_QUEUE_DELAY_SECONDS = 0 +INITIAL_SCHOLAR_SCRAPE_QUEUE_REASON = "scholar_added_initial_scrape" + + +def needs_metadata_hydration(profile) -> bool: + if not profile.profile_image_url: + return True + return not (profile.display_name or "").strip() + + +def is_create_hydration_rate_limited() -> tuple[bool, float]: + remaining_seconds = scholar_rate_limit.remaining_scholar_slot_seconds( + min_interval_seconds=float(settings.ingestion_min_request_delay_seconds), + ) + return remaining_seconds > 0, remaining_seconds + + +def auto_enqueue_new_scholar_enabled() -> bool: + if not settings.scheduler_enabled: + return False + if not settings.ingestion_automation_allowed: + return False + return bool(settings.ingestion_continuation_queue_enabled) + + +async def enqueue_initial_scrape_job_for_scholar( + db_session: AsyncSession, + *, + profile, + user_id: int, +) -> bool: + if not auto_enqueue_new_scholar_enabled(): + return False + try: + await ingestion_queue_service.upsert_job( + db_session, + user_id=user_id, + scholar_profile_id=int(profile.id), + resume_cstart=0, + reason=INITIAL_SCHOLAR_SCRAPE_QUEUE_REASON, + run_id=None, + delay_seconds=INITIAL_SCHOLAR_SCRAPE_QUEUE_DELAY_SECONDS, + ) + await db_session.commit() + except Exception: + await db_session.rollback() + structured_log( + logger, + "warning", + "api.scholars.initial_scrape_enqueue_failed", + user_id=user_id, + scholar_profile_id=profile.id, + ) + return False + + structured_log( + logger, + "info", + "api.scholars.initial_scrape_enqueued", + user_id=user_id, + scholar_profile_id=profile.id, + reason=INITIAL_SCHOLAR_SCRAPE_QUEUE_REASON, + ) + return True + + +def uploaded_image_media_path(scholar_profile_id: int) -> str: + return f"/scholar-images/{scholar_profile_id}/upload" + + +def serialize_scholar(profile) -> dict[str, object]: + uploaded_image_url = None + if profile.profile_image_upload_path: + uploaded_image_url = uploaded_image_media_path(int(profile.id)) + + profile_image_url, profile_image_source = scholar_search_hints.resolve_profile_image( + profile, + uploaded_image_url=uploaded_image_url, + ) + + return { + "id": int(profile.id), + "scholar_id": profile.scholar_id, + "display_name": profile.display_name, + "profile_image_url": profile_image_url, + "profile_image_source": profile_image_source, + "is_enabled": bool(profile.is_enabled), + "baseline_completed": bool(profile.baseline_completed), + "last_run_dt": profile.last_run_dt, + "last_run_status": (profile.last_run_status.value if profile.last_run_status is not None else None), + } + + +async def hydrate_scholar_metadata_if_needed( + db_session: AsyncSession, + *, + profile, + source: ScholarSource, + user_id: int, +): + if not needs_metadata_hydration(profile): + return profile + + should_skip, remaining_seconds = is_create_hydration_rate_limited() + if should_skip: + structured_log( + logger, + "info", + "api.scholars.create_metadata_hydration_skipped", + reason="scholar_request_throttle_active", + user_id=user_id, + scholar_profile_id=profile.id, + retry_after_seconds=round(remaining_seconds, 3), + ) + return profile + + try: + return await asyncio.wait_for( + scholar_service.hydrate_profile_metadata( + db_session, + profile=profile, + source=source, + ), + timeout=CREATE_METADATA_HYDRATION_TIMEOUT_SECONDS, + ) + except TimeoutError: + structured_log( + logger, + "info", + "api.scholars.create_metadata_hydration_skipped", + reason="create_timeout", + user_id=user_id, + scholar_profile_id=profile.id, + ) + except Exception: + structured_log( + logger, + "warning", + "api.scholars.create_metadata_hydration_failed", + user_id=user_id, + scholar_profile_id=profile.id, + ) + return profile + + +def search_kwargs() -> dict[str, Any]: + return { + "network_error_retries": settings.ingestion_network_error_retries, + "retry_backoff_seconds": settings.ingestion_retry_backoff_seconds, + "search_enabled": settings.scholar_name_search_enabled, + "cache_ttl_seconds": settings.scholar_name_search_cache_ttl_seconds, + "blocked_cache_ttl_seconds": settings.scholar_name_search_blocked_cache_ttl_seconds, + "cache_max_entries": settings.scholar_name_search_cache_max_entries, + "min_interval_seconds": settings.scholar_name_search_min_interval_seconds, + "interval_jitter_seconds": settings.scholar_name_search_interval_jitter_seconds, + "cooldown_block_threshold": settings.scholar_name_search_cooldown_block_threshold, + "cooldown_seconds": settings.scholar_name_search_cooldown_seconds, + "retry_alert_threshold": settings.scholar_name_search_alert_retry_count_threshold, + "cooldown_rejection_alert_threshold": (settings.scholar_name_search_alert_cooldown_rejections_threshold), + } + + +def search_response_data(query: str, parsed) -> dict[str, object]: + return { + "query": query.strip(), + "state": parsed.state.value, + "state_reason": parsed.state_reason, + "action_hint": scholar_search_hints.scrape_state_hint( + state=parsed.state, + state_reason=parsed.state_reason, + ), + "candidates": [ + { + "scholar_id": item.scholar_id, + "display_name": item.display_name, + "affiliation": item.affiliation, + "email_domain": item.email_domain, + "cited_by_count": item.cited_by_count, + "interests": item.interests, + "profile_url": item.profile_url, + "profile_image_url": item.profile_image_url, + } + for item in parsed.candidates + ], + "warnings": parsed.warnings, + } + + +async def read_uploaded_image(image: UploadFile) -> bytes: + try: + return await image.read() + finally: + await image.close() + + +async def require_user_profile( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int, +): + profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=user_id, + scholar_profile_id=scholar_profile_id, + ) + if profile is None: + raise ApiException( + status_code=404, + code="scholar_not_found", + message="Scholar not found.", + ) + return profile diff --git a/app/api/routers/scholars.py b/app/api/routers/scholars.py index f4be187..7095471 100644 --- a/app/api/routers/scholars.py +++ b/app/api/routers/scholars.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import logging from fastapi import APIRouter, Depends, File, Query, Request, UploadFile @@ -9,6 +8,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_api_current_user from app.api.errors import ApiException from app.api.responses import success_payload +from app.api.routers.scholar_helpers import ( + enqueue_initial_scrape_job_for_scholar, + hydrate_scholar_metadata_if_needed, + read_uploaded_image, + require_user_profile, + search_kwargs, + search_response_data, + serialize_scholar, +) from app.api.runtime_deps import get_scholar_source from app.api.schemas import ( DataExportEnvelope, @@ -23,9 +31,10 @@ from app.api.schemas import ( ) from app.db.models import User from app.db.session import get_db_session -from app.services.domains.portability import application as import_export_service -from app.services.domains.scholars import application as scholar_service -from app.services.domains.scholar.source import ScholarSource +from app.logging_utils import structured_log +from app.services.portability import application as import_export_service +from app.services.scholar.source import ScholarSource +from app.services.scholars import application as scholar_service from app.settings import settings logger = logging.getLogger(__name__) @@ -33,136 +42,6 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/scholars", tags=["api-scholars"]) -def _uploaded_image_media_path(scholar_profile_id: int) -> str: - return f"/scholar-images/{scholar_profile_id}/upload" - - -def _serialize_scholar(profile) -> dict[str, object]: - uploaded_image_url = None - if profile.profile_image_upload_path: - uploaded_image_url = _uploaded_image_media_path(int(profile.id)) - - profile_image_url, profile_image_source = scholar_service.resolve_profile_image( - profile, - uploaded_image_url=uploaded_image_url, - ) - - return { - "id": int(profile.id), - "scholar_id": profile.scholar_id, - "display_name": profile.display_name, - "profile_image_url": profile_image_url, - "profile_image_source": profile_image_source, - "is_enabled": bool(profile.is_enabled), - "baseline_completed": bool(profile.baseline_completed), - "last_run_dt": profile.last_run_dt, - "last_run_status": ( - profile.last_run_status.value if profile.last_run_status is not None else None - ), - } - - -async def _hydrate_scholar_metadata_if_needed( - db_session: AsyncSession, - *, - profile, - source: ScholarSource, - user_id: int, -): - try: - if not profile.profile_image_url or not (profile.display_name or "").strip(): - return await asyncio.wait_for( - scholar_service.hydrate_profile_metadata( - db_session, - profile=profile, - source=source, - ), - timeout=5.0, - ) - except Exception: - logger.warning( - "api.scholars.create_metadata_hydration_failed", - extra={ - "event": "api.scholars.create_metadata_hydration_failed", - "user_id": user_id, - "scholar_profile_id": profile.id, - }, - ) - return profile - - -def _search_kwargs() -> dict[str, object]: - return { - "network_error_retries": settings.ingestion_network_error_retries, - "retry_backoff_seconds": settings.ingestion_retry_backoff_seconds, - "search_enabled": settings.scholar_name_search_enabled, - "cache_ttl_seconds": settings.scholar_name_search_cache_ttl_seconds, - "blocked_cache_ttl_seconds": settings.scholar_name_search_blocked_cache_ttl_seconds, - "cache_max_entries": settings.scholar_name_search_cache_max_entries, - "min_interval_seconds": settings.scholar_name_search_min_interval_seconds, - "interval_jitter_seconds": settings.scholar_name_search_interval_jitter_seconds, - "cooldown_block_threshold": settings.scholar_name_search_cooldown_block_threshold, - "cooldown_seconds": settings.scholar_name_search_cooldown_seconds, - "retry_alert_threshold": settings.scholar_name_search_alert_retry_count_threshold, - "cooldown_rejection_alert_threshold": ( - settings.scholar_name_search_alert_cooldown_rejections_threshold - ), - } - - -def _search_response_data(query: str, parsed) -> dict[str, object]: - return { - "query": query.strip(), - "state": parsed.state.value, - "state_reason": parsed.state_reason, - "action_hint": scholar_service.scrape_state_hint( - state=parsed.state, - state_reason=parsed.state_reason, - ), - "candidates": [ - { - "scholar_id": item.scholar_id, - "display_name": item.display_name, - "affiliation": item.affiliation, - "email_domain": item.email_domain, - "cited_by_count": item.cited_by_count, - "interests": item.interests, - "profile_url": item.profile_url, - "profile_image_url": item.profile_image_url, - } - for item in parsed.candidates - ], - "warnings": parsed.warnings, - } - - -async def _read_uploaded_image(image: UploadFile) -> bytes: - try: - return await image.read() - finally: - await image.close() - - -async def _require_user_profile( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_id: int, -): - profile = await scholar_service.get_user_scholar_by_id( - db_session, - user_id=user_id, - scholar_profile_id=scholar_profile_id, - ) - if profile is None: - raise ApiException( - status_code=404, - code="scholar_not_found", - message="Scholar not found.", - ) - return profile - - @router.get( "", response_model=ScholarsListEnvelope, @@ -179,7 +58,7 @@ async def list_scholars( return success_payload( request, data={ - "scholars": [_serialize_scholar(profile) for profile in scholars], + "scholars": [serialize_scholar(profile) for profile in scholars], }, ) @@ -218,8 +97,7 @@ async def import_scholars_and_publications( status_code=400, code="invalid_import_schema_version", message=( - "Import schema version is not supported. " - f"Expected {import_export_service.EXPORT_SCHEMA_VERSION}." + f"Import schema version is not supported. Expected {import_export_service.EXPORT_SCHEMA_VERSION}." ), ) try: @@ -235,14 +113,7 @@ async def import_scholars_and_publications( code="invalid_import_payload", message=str(exc), ) from exc - logger.info( - "api.scholars.imported", - extra={ - "event": "api.scholars.imported", - "user_id": current_user.id, - **result, - }, - ) + structured_log(logger, "info", "api.scholars.imported", user_id=current_user.id, **result) return success_payload(request, data=result) @@ -272,24 +143,23 @@ async def create_scholar( code="invalid_scholar", message=str(exc), ) from exc - logger.info( - "api.scholars.created", - extra={ - "event": "api.scholars.created", - "user_id": current_user.id, - "scholar_profile_id": created.id, - }, - ) - created = await _hydrate_scholar_metadata_if_needed( + structured_log(logger, "info", "api.scholars.created", user_id=current_user.id, scholar_profile_id=created.id) + did_queue_initial_scrape = await enqueue_initial_scrape_job_for_scholar( db_session, profile=created, - source=source, user_id=current_user.id, ) + if not did_queue_initial_scrape: + created = await hydrate_scholar_metadata_if_needed( + db_session, + profile=created, + source=source, + user_id=current_user.id, + ) return success_payload( request, - data=_serialize_scholar(created), + data=serialize_scholar(created), ) @@ -311,7 +181,7 @@ async def search_scholars( db_session=db_session, query=query, limit=limit, - **_search_kwargs(), + **search_kwargs(), ) except scholar_service.ScholarServiceError as exc: raise ApiException( @@ -320,19 +190,18 @@ async def search_scholars( message=str(exc), ) from exc - logger.info( + structured_log( + logger, + "info", "api.scholars.search.completed", - extra={ - "event": "api.scholars.search.completed", - "user_id": current_user.id, - "query": query.strip(), - "candidate_count": len(parsed.candidates), - "state": parsed.state.value, - }, + user_id=current_user.id, + query=query.strip(), + candidate_count=len(parsed.candidates), + state=parsed.state.value, ) return success_payload( request, - data=_search_response_data(query, parsed), + data=search_response_data(query, parsed), ) @@ -358,18 +227,17 @@ async def toggle_scholar( message="Scholar not found.", ) updated = await scholar_service.toggle_scholar_enabled(db_session, profile=profile) - logger.info( + structured_log( + logger, + "info", "api.scholars.toggled", - extra={ - "event": "api.scholars.toggled", - "user_id": current_user.id, - "scholar_profile_id": updated.id, - "is_enabled": updated.is_enabled, - }, + user_id=current_user.id, + scholar_profile_id=updated.id, + is_enabled=updated.is_enabled, ) return success_payload( request, - data=_serialize_scholar(updated), + data=serialize_scholar(updated), ) @@ -399,13 +267,8 @@ async def delete_scholar( profile=profile, upload_dir=settings.scholar_image_upload_dir, ) - logger.info( - "api.scholars.deleted", - extra={ - "event": "api.scholars.deleted", - "user_id": current_user.id, - "scholar_profile_id": scholar_profile_id, - }, + structured_log( + logger, "info", "api.scholars.deleted", user_id=current_user.id, scholar_profile_id=scholar_profile_id ) return success_payload( request, @@ -450,17 +313,12 @@ async def update_scholar_image_url( message=str(exc), ) from exc - logger.info( - "api.scholars.image_url_updated", - extra={ - "event": "api.scholars.image_url_updated", - "user_id": current_user.id, - "scholar_profile_id": updated.id, - }, + structured_log( + logger, "info", "api.scholars.image_url_updated", user_id=current_user.id, scholar_profile_id=updated.id ) return success_payload( request, - data=_serialize_scholar(updated), + data=serialize_scholar(updated), ) @@ -475,13 +333,13 @@ async def upload_scholar_image( db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): - profile = await _require_user_profile( + profile = await require_user_profile( db_session, user_id=current_user.id, scholar_profile_id=scholar_profile_id, ) - image_bytes = await _read_uploaded_image(image) + image_bytes = await read_uploaded_image(image) try: updated = await scholar_service.set_profile_image_upload( db_session, @@ -499,19 +357,18 @@ async def upload_scholar_image( ) from exc image_size = len(image_bytes) - logger.info( + structured_log( + logger, + "info", "api.scholars.image_uploaded", - extra={ - "event": "api.scholars.image_uploaded", - "user_id": current_user.id, - "scholar_profile_id": updated.id, - "content_type": image.content_type, - "size_bytes": image_size, - }, + user_id=current_user.id, + scholar_profile_id=updated.id, + content_type=image.content_type, + size_bytes=image_size, ) return success_payload( request, - data=_serialize_scholar(updated), + data=serialize_scholar(updated), ) @@ -542,16 +399,8 @@ async def clear_scholar_image_customization( profile=profile, upload_dir=settings.scholar_image_upload_dir, ) - logger.info( - "api.scholars.image_customization_cleared", - extra={ - "event": "api.scholars.image_customization_cleared", - "user_id": current_user.id, - "scholar_profile_id": updated.id, - }, - ) + structured_log(logger, "info", "api.scholars.image_cleared", user_id=current_user.id, scholar_profile_id=updated.id) return success_payload( request, - data=_serialize_scholar(updated), + data=serialize_scholar(updated), ) - diff --git a/app/api/routers/settings.py b/app/api/routers/settings.py index f5e9cac..a3cc88f 100644 --- a/app/api/routers/settings.py +++ b/app/api/routers/settings.py @@ -11,8 +11,9 @@ from app.api.responses import success_payload from app.api.schemas import SettingsEnvelope, SettingsUpdateRequest from app.db.models import User from app.db.session import get_db_session -from app.services.domains.ingestion import safety as run_safety_service -from app.services.domains.settings import application as user_settings_service +from app.logging_utils import structured_log +from app.services.ingestion import safety as run_safety_service +from app.services.settings import application as user_settings_service from app.settings import settings as settings_module logger = logging.getLogger(__name__) @@ -38,6 +39,9 @@ def _serialize_settings(user_settings) -> dict[str, object]: "cooldown_network_seconds": max(60, int(settings_module.ingestion_safety_cooldown_network_seconds)), }, "safety_state": run_safety_service.get_safety_state_payload(user_settings), + "openalex_api_key": user_settings.openalex_api_key, + "crossref_api_token": user_settings.crossref_api_token, + "crossref_api_mailto": user_settings.crossref_api_mailto, } @@ -80,16 +84,13 @@ async def _clear_expired_cooldown_with_log( return await db_session.commit() await db_session.refresh(user_settings) - logger.info( + structured_log( + logger, + "info", "api.settings.safety_cooldown_cleared", - extra={ - "event": "api.settings.safety_cooldown_cleared", - "user_id": user_id, - "reason": previous_safety_state.get("cooldown_reason"), - "cooldown_until": previous_safety_state.get("cooldown_until"), - "metric_name": "api_settings_safety_cooldown_cleared_total", - "metric_value": 1, - }, + user_id=user_id, + reason=previous_safety_state.get("cooldown_reason"), + cooldown_until=previous_safety_state.get("cooldown_until"), ) @@ -97,20 +98,6 @@ def _effective_auto_run_enabled(payload: SettingsUpdateRequest) -> bool: return bool(payload.auto_run_enabled) and settings_module.ingestion_automation_allowed -def _log_settings_update(*, user_id: int, updated) -> None: - logger.info( - "api.settings.updated", - extra={ - "event": "api.settings.updated", - "user_id": user_id, - "auto_run_enabled": updated.auto_run_enabled, - "run_interval_minutes": updated.run_interval_minutes, - "request_delay_seconds": updated.request_delay_seconds, - "nav_visible_pages": updated.nav_visible_pages, - }, - ) - - @router.get( "", response_model=SettingsEnvelope, @@ -169,13 +156,26 @@ async def update_settings( run_interval_minutes=parsed_interval, request_delay_seconds=parsed_delay, nav_visible_pages=parsed_nav_visible_pages, + openalex_api_key=payload.openalex_api_key, + crossref_api_token=payload.crossref_api_token, + crossref_api_mailto=payload.crossref_api_mailto, ) await _clear_expired_cooldown_with_log( db_session, user_id=current_user.id, user_settings=updated, ) - _log_settings_update(user_id=current_user.id, updated=updated) + structured_log( + logger, + "info", + "api.settings.updated", + user_id=current_user.id, + auto_run_enabled=updated.auto_run_enabled, + run_interval_minutes=updated.run_interval_minutes, + request_delay_seconds=updated.request_delay_seconds, + nav_visible_pages=updated.nav_visible_pages, + openalex_api_key="SET" if updated.openalex_api_key else "UNSET", + ) return success_payload( request, data=_serialize_settings(updated), diff --git a/app/api/runtime_deps.py b/app/api/runtime_deps.py index 3a692a3..5a6e9de 100644 --- a/app/api/runtime_deps.py +++ b/app/api/runtime_deps.py @@ -2,8 +2,8 @@ from __future__ import annotations from fastapi import Depends -from app.services.domains.ingestion import application as ingestion_service -from app.services.domains.scholar import source as scholar_source_service +from app.services.ingestion import application as ingestion_service +from app.services.scholar import source as scholar_source_service def get_scholar_source() -> scholar_source_service.ScholarSource: diff --git a/app/api/schemas.py b/app/api/schemas.py deleted file mode 100644 index 3ae8876..0000000 --- a/app/api/schemas.py +++ /dev/null @@ -1,867 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field, model_validator - - -class ApiMeta(BaseModel): - request_id: str | None = None - - model_config = ConfigDict(extra="forbid") - - -class ApiErrorData(BaseModel): - code: str - message: str - details: Any | None = None - - model_config = ConfigDict(extra="forbid") - - -class ApiErrorEnvelope(BaseModel): - error: ApiErrorData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class MessageData(BaseModel): - message: str - - model_config = ConfigDict(extra="forbid") - - -class MessageEnvelope(BaseModel): - data: MessageData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class SessionUserData(BaseModel): - id: int - email: str - is_admin: bool - is_active: bool - - model_config = ConfigDict(extra="forbid") - - -class AuthMeData(BaseModel): - authenticated: bool - csrf_token: str - user: SessionUserData - - model_config = ConfigDict(extra="forbid") - - -class AuthMeEnvelope(BaseModel): - data: AuthMeData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class CsrfBootstrapData(BaseModel): - csrf_token: str - authenticated: bool - - model_config = ConfigDict(extra="forbid") - - -class CsrfBootstrapEnvelope(BaseModel): - data: CsrfBootstrapData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class LoginRequest(BaseModel): - email: str - password: str - - model_config = ConfigDict(extra="forbid") - - -class LoginData(BaseModel): - authenticated: bool - csrf_token: str - user: SessionUserData - - model_config = ConfigDict(extra="forbid") - - -class LoginEnvelope(BaseModel): - data: LoginData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class ChangePasswordRequest(BaseModel): - current_password: str - new_password: str - confirm_password: str - - model_config = ConfigDict(extra="forbid") - - -class ScholarItemData(BaseModel): - id: int - scholar_id: str - display_name: str | None - profile_image_url: str | None - profile_image_source: str - is_enabled: bool - baseline_completed: bool - last_run_dt: datetime | None - last_run_status: str | None - - model_config = ConfigDict(extra="forbid") - - -class ScholarsListData(BaseModel): - scholars: list[ScholarItemData] - - model_config = ConfigDict(extra="forbid") - - -class ScholarsListEnvelope(BaseModel): - data: ScholarsListData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class ScholarCreateRequest(BaseModel): - scholar_id: str - profile_image_url: str | None = None - - model_config = ConfigDict(extra="forbid") - - -class ScholarEnvelope(BaseModel): - data: ScholarItemData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class ScholarSearchCandidateData(BaseModel): - scholar_id: str - display_name: str - affiliation: str | None - email_domain: str | None - cited_by_count: int | None - interests: list[str] = Field(default_factory=list) - profile_url: str - profile_image_url: str | None - - model_config = ConfigDict(extra="forbid") - - -class ScholarSearchData(BaseModel): - query: str - state: str - state_reason: str - action_hint: str | None = None - candidates: list[ScholarSearchCandidateData] - warnings: list[str] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - -class ScholarSearchEnvelope(BaseModel): - data: ScholarSearchData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class ScholarImageUrlUpdateRequest(BaseModel): - image_url: str - - model_config = ConfigDict(extra="forbid") - - -class ScholarExportItemData(BaseModel): - scholar_id: str - display_name: str | None = None - is_enabled: bool = True - profile_image_override_url: str | None = None - - model_config = ConfigDict(extra="forbid") - - -class PublicationExportItemData(BaseModel): - scholar_id: str - cluster_id: str | None = None - fingerprint_sha256: str | None = None - title: str - year: int | None = None - citation_count: int = 0 - author_text: str | None = None - venue_text: str | None = None - pub_url: str | None = None - doi: str | None = None - pdf_url: str | None = None - is_read: bool = False - - model_config = ConfigDict(extra="forbid") - - -class DataExportData(BaseModel): - schema_version: int - exported_at: str - scholars: list[ScholarExportItemData] - publications: list[PublicationExportItemData] - - model_config = ConfigDict(extra="forbid") - - -class DataExportEnvelope(BaseModel): - data: DataExportData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class DataImportRequest(BaseModel): - schema_version: int | None = None - scholars: list[ScholarExportItemData] = Field(default_factory=list) - publications: list[PublicationExportItemData] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - -class DataImportResultData(BaseModel): - scholars_created: int - scholars_updated: int - publications_created: int - publications_updated: int - links_created: int - links_updated: int - skipped_records: int - - model_config = ConfigDict(extra="forbid") - - -class DataImportEnvelope(BaseModel): - data: DataImportResultData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class RunListItemData(BaseModel): - id: int - trigger_type: str - status: str - start_dt: datetime - end_dt: datetime | None - scholar_count: int - new_publication_count: int - failed_count: int - partial_count: int - - model_config = ConfigDict(extra="forbid") - - -class RunsListData(BaseModel): - runs: list[RunListItemData] - safety_state: ScrapeSafetyStateData - - model_config = ConfigDict(extra="forbid") - - -class RunsListEnvelope(BaseModel): - data: RunsListData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class RunSummaryData(BaseModel): - succeeded_count: int - failed_count: int - partial_count: int - failed_state_counts: dict[str, int] = Field(default_factory=dict) - failed_reason_counts: dict[str, int] = Field(default_factory=dict) - scrape_failure_counts: dict[str, int] = Field(default_factory=dict) - retry_counts: dict[str, int] = Field(default_factory=dict) - alert_thresholds: dict[str, int] = Field(default_factory=dict) - alert_flags: dict[str, bool] = Field(default_factory=dict) - - model_config = ConfigDict(extra="forbid") - - -class ScrapeSafetyCountersData(BaseModel): - consecutive_blocked_runs: int = 0 - consecutive_network_runs: int = 0 - cooldown_entry_count: int = 0 - blocked_start_count: int = 0 - last_blocked_failure_count: int = 0 - last_network_failure_count: int = 0 - last_evaluated_run_id: int | None = None - - model_config = ConfigDict(extra="forbid") - - -class ScrapeSafetyStateData(BaseModel): - cooldown_active: bool - cooldown_reason: str | None = None - cooldown_reason_label: str | None = None - cooldown_until: datetime | None = None - cooldown_remaining_seconds: int = 0 - recommended_action: str | None = None - counters: ScrapeSafetyCountersData = Field(default_factory=ScrapeSafetyCountersData) - - model_config = ConfigDict(extra="forbid") - - -class RunAttemptLogData(BaseModel): - attempt: int - cstart: int - state: str | None = None - state_reason: str | None = None - status_code: int | None = None - fetch_error: str | None = None - - model_config = ConfigDict(extra="forbid") - - -class RunPageLogData(BaseModel): - page: int - cstart: int - state: str - state_reason: str | None = None - status_code: int | None = None - publication_count: int = 0 - attempt_count: int = 0 - has_show_more_button: bool = False - articles_range: str | None = None - warning_codes: list[str] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - -class RunDebugData(BaseModel): - status_code: int | None = None - final_url: str | None = None - fetch_error: str | None = None - body_sha256: str | None = None - body_length: int | None = None - has_show_more_button: bool | None = None - articles_range: str | None = None - state_reason: str | None = None - warning_codes: list[str] = Field(default_factory=list) - marker_counts_nonzero: dict[str, int] = Field(default_factory=dict) - page_logs: list[RunPageLogData] = Field(default_factory=list) - attempt_log: list[RunAttemptLogData] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - -class RunScholarResultData(BaseModel): - scholar_profile_id: int - scholar_id: str - state: str - state_reason: str | None = None - outcome: str - attempt_count: int = 0 - publication_count: int = 0 - start_cstart: int = 0 - continuation_cstart: int | None = None - continuation_enqueued: bool = False - continuation_cleared: bool = False - warnings: list[str] = Field(default_factory=list) - error: str | None = None - debug: RunDebugData | None = None - - model_config = ConfigDict(extra="forbid") - - -class RunDetailData(BaseModel): - run: RunListItemData - summary: RunSummaryData - scholar_results: list[RunScholarResultData] - safety_state: ScrapeSafetyStateData - - model_config = ConfigDict(extra="forbid") - - -class RunDetailEnvelope(BaseModel): - data: RunDetailData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class ManualRunData(BaseModel): - run_id: int - status: str - scholar_count: int - succeeded_count: int - failed_count: int - partial_count: int - new_publication_count: int - reused_existing_run: bool - idempotency_key: str | None = None - safety_state: ScrapeSafetyStateData - - model_config = ConfigDict(extra="forbid") - - -class ManualRunEnvelope(BaseModel): - data: ManualRunData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class QueueItemData(BaseModel): - id: int - scholar_profile_id: int - scholar_label: str - status: str - reason: str - dropped_reason: str | None - attempt_count: int - resume_cstart: int - next_attempt_dt: datetime | None - updated_at: datetime - last_error: str | None - last_run_id: int | None - - model_config = ConfigDict(extra="forbid") - - -class QueueListData(BaseModel): - queue_items: list[QueueItemData] - - model_config = ConfigDict(extra="forbid") - - -class QueueListEnvelope(BaseModel): - data: QueueListData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class QueueItemEnvelope(BaseModel): - data: QueueItemData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class QueueClearData(BaseModel): - queue_item_id: int - previous_status: str - status: str - message: str - - model_config = ConfigDict(extra="forbid") - - -class QueueClearEnvelope(BaseModel): - data: QueueClearData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminUserData(BaseModel): - id: int - email: str - is_active: bool - is_admin: bool - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(extra="forbid") - - -class AdminUsersListData(BaseModel): - users: list[AdminUserData] - - model_config = ConfigDict(extra="forbid") - - -class AdminUsersListEnvelope(BaseModel): - data: AdminUsersListData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminUserCreateRequest(BaseModel): - email: str - password: str - is_admin: bool = False - - model_config = ConfigDict(extra="forbid") - - -class AdminUserActiveUpdateRequest(BaseModel): - is_active: bool - - model_config = ConfigDict(extra="forbid") - - -class AdminUserEnvelope(BaseModel): - data: AdminUserData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminResetPasswordRequest(BaseModel): - new_password: str - - model_config = ConfigDict(extra="forbid") - - -class AdminDbIntegrityCheckData(BaseModel): - name: str - count: int - severity: str - message: str - - model_config = ConfigDict(extra="forbid") - - -class AdminDbIntegrityData(BaseModel): - status: str - checked_at: datetime - failures: list[str] = Field(default_factory=list) - warnings: list[str] = Field(default_factory=list) - checks: list[AdminDbIntegrityCheckData] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - -class AdminDbIntegrityEnvelope(BaseModel): - data: AdminDbIntegrityData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminDbRepairJobData(BaseModel): - id: int - job_name: str - requested_by: str | None - dry_run: bool - status: str - scope: dict[str, Any] = Field(default_factory=dict) - summary: dict[str, Any] = Field(default_factory=dict) - error_text: str | None - started_at: datetime | None - finished_at: datetime | None - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(extra="forbid") - - -class AdminDbRepairJobsData(BaseModel): - jobs: list[AdminDbRepairJobData] = Field(default_factory=list) - - model_config = ConfigDict(extra="forbid") - - -class AdminDbRepairJobsEnvelope(BaseModel): - data: AdminDbRepairJobsData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueItemData(BaseModel): - publication_id: int - title: str - doi: str | None - pdf_url: str | None - status: str - attempt_count: int - last_failure_reason: str | None - last_failure_detail: str | None - last_source: str | None - requested_by_user_id: int | None - requested_by_email: str | None - queued_at: datetime | None - last_attempt_at: datetime | None - resolved_at: datetime | None - updated_at: datetime - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueData(BaseModel): - items: list[AdminPdfQueueItemData] = Field(default_factory=list) - total_count: int = 0 - page: int = 1 - page_size: int = 100 - has_next: bool = False - has_prev: bool = False - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueEnvelope(BaseModel): - data: AdminPdfQueueData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueRequeueData(BaseModel): - publication_id: int - queued: bool - status: str - message: str - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueRequeueEnvelope(BaseModel): - data: AdminPdfQueueRequeueData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueBulkEnqueueData(BaseModel): - requested_count: int - queued_count: int - message: str - - model_config = ConfigDict(extra="forbid") - - -class AdminPdfQueueBulkEnqueueEnvelope(BaseModel): - data: AdminPdfQueueBulkEnqueueData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class AdminRepairPublicationLinksRequest(BaseModel): - scope_mode: Literal["single_user", "all_users"] = "single_user" - user_id: int | None = Field(default=None, ge=1) - scholar_profile_ids: list[int] = Field(default_factory=list, max_length=200) - dry_run: bool = True - gc_orphan_publications: bool = False - requested_by: str | None = None - confirmation_text: str | None = None - - model_config = ConfigDict(extra="forbid") - - @model_validator(mode="after") - def validate_scope(self) -> "AdminRepairPublicationLinksRequest": - if self.scope_mode == "single_user" and self.user_id is None: - raise ValueError("user_id is required when scope_mode=single_user.") - if self.scope_mode == "all_users" and self.user_id is not None: - raise ValueError("user_id must be omitted when scope_mode=all_users.") - if not self.dry_run and self.scope_mode == "all_users": - expected = "REPAIR ALL USERS" - provided = (self.confirmation_text or "").strip() - if provided != expected: - raise ValueError( - "confirmation_text must equal 'REPAIR ALL USERS' " - "when applying a repair to all users." - ) - return self - - -class AdminRepairPublicationLinksResultData(BaseModel): - job_id: int - status: str - scope: dict[str, Any] = Field(default_factory=dict) - summary: dict[str, Any] = Field(default_factory=dict) - - model_config = ConfigDict(extra="forbid") - - -class AdminRepairPublicationLinksEnvelope(BaseModel): - data: AdminRepairPublicationLinksResultData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class SettingsPolicyData(BaseModel): - min_run_interval_minutes: int - min_request_delay_seconds: int - automation_allowed: bool - manual_run_allowed: bool - blocked_failure_threshold: int - network_failure_threshold: int - cooldown_blocked_seconds: int - cooldown_network_seconds: int - - model_config = ConfigDict(extra="forbid") - - -class SettingsData(BaseModel): - auto_run_enabled: bool - run_interval_minutes: int - request_delay_seconds: int - nav_visible_pages: list[str] - policy: SettingsPolicyData - safety_state: ScrapeSafetyStateData - - model_config = ConfigDict(extra="forbid") - - -class SettingsEnvelope(BaseModel): - data: SettingsData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class SettingsUpdateRequest(BaseModel): - auto_run_enabled: bool - run_interval_minutes: int - request_delay_seconds: int - nav_visible_pages: list[str] | None = None - - model_config = ConfigDict(extra="forbid") - - -class PublicationItemData(BaseModel): - publication_id: int - scholar_profile_id: int - scholar_label: str - title: str - year: int | None - citation_count: int - venue_text: str | None - pub_url: str | None - doi: str | None - pdf_url: str | None - pdf_status: str = "untracked" - pdf_attempt_count: int = 0 - pdf_failure_reason: str | None = None - pdf_failure_detail: str | None = None - is_read: bool - is_favorite: bool = False - first_seen_at: datetime - is_new_in_latest_run: bool - - model_config = ConfigDict(extra="forbid") - - -class PublicationsListData(BaseModel): - mode: str - favorite_only: bool = False - selected_scholar_profile_id: int | None - unread_count: int - favorites_count: int - latest_count: int - new_count: int - total_count: int - page: int - page_size: int - has_next: bool = False - has_prev: bool = False - publications: list[PublicationItemData] - - model_config = ConfigDict(extra="forbid") - - -class PublicationsListEnvelope(BaseModel): - data: PublicationsListData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class MarkAllReadData(BaseModel): - message: str - updated_count: int - - model_config = ConfigDict(extra="forbid") - - -class MarkAllReadEnvelope(BaseModel): - data: MarkAllReadData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class PublicationSelectionItem(BaseModel): - scholar_profile_id: int - publication_id: int - - model_config = ConfigDict(extra="forbid") - - -class MarkSelectedReadRequest(BaseModel): - selections: list[PublicationSelectionItem] = Field(min_length=1, max_length=500) - - model_config = ConfigDict(extra="forbid") - - -class MarkSelectedReadData(BaseModel): - message: str - requested_count: int - updated_count: int - - model_config = ConfigDict(extra="forbid") - - -class MarkSelectedReadEnvelope(BaseModel): - data: MarkSelectedReadData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class RetryPublicationPdfRequest(BaseModel): - scholar_profile_id: int = Field(ge=1) - - model_config = ConfigDict(extra="forbid") - - -class RetryPublicationPdfData(BaseModel): - message: str - queued: bool - resolved_pdf: bool - publication: PublicationItemData - - model_config = ConfigDict(extra="forbid") - - -class RetryPublicationPdfEnvelope(BaseModel): - data: RetryPublicationPdfData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") - - -class TogglePublicationFavoriteRequest(BaseModel): - scholar_profile_id: int = Field(ge=1) - is_favorite: bool - - model_config = ConfigDict(extra="forbid") - - -class TogglePublicationFavoriteData(BaseModel): - message: str - publication: PublicationItemData - - model_config = ConfigDict(extra="forbid") - - -class TogglePublicationFavoriteEnvelope(BaseModel): - data: TogglePublicationFavoriteData - meta: ApiMeta - - model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/__init__.py b/app/api/schemas/__init__.py new file mode 100644 index 0000000..019d86a --- /dev/null +++ b/app/api/schemas/__init__.py @@ -0,0 +1,7 @@ +from app.api.schemas.admin import * # noqa: F403 +from app.api.schemas.auth import * # noqa: F403 +from app.api.schemas.common import * # noqa: F403 +from app.api.schemas.publications import * # noqa: F403 +from app.api.schemas.runs import * # noqa: F403 +from app.api.schemas.scholars import * # noqa: F403 +from app.api.schemas.settings import * # noqa: F403 diff --git a/app/api/schemas/admin.py b/app/api/schemas/admin.py new file mode 100644 index 0000000..932f93c --- /dev/null +++ b/app/api/schemas/admin.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.api.schemas.common import ApiMeta +from app.api.schemas.publications import DisplayIdentifierData + + +class AdminUserData(BaseModel): + id: int + email: str + is_active: bool + is_admin: bool + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(extra="forbid") + + +class AdminUsersListData(BaseModel): + users: list[AdminUserData] + + model_config = ConfigDict(extra="forbid") + + +class AdminUsersListEnvelope(BaseModel): + data: AdminUsersListData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminUserCreateRequest(BaseModel): + email: str = Field(max_length=254) + password: str = Field(min_length=8, max_length=128) + is_admin: bool = False + + model_config = ConfigDict(extra="forbid") + + +class AdminUserActiveUpdateRequest(BaseModel): + is_active: bool + + model_config = ConfigDict(extra="forbid") + + +class AdminUserEnvelope(BaseModel): + data: AdminUserData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminResetPasswordRequest(BaseModel): + new_password: str = Field(min_length=8, max_length=128) + + model_config = ConfigDict(extra="forbid") + + +class AdminScholarHttpSettingsData(BaseModel): + user_agent: str + rotate_user_agent: bool + accept_language: str + cookie: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class AdminScholarHttpSettingsEnvelope(BaseModel): + data: AdminScholarHttpSettingsData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminScholarHttpSettingsUpdateRequest(BaseModel): + user_agent: str + rotate_user_agent: bool + accept_language: str + cookie: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class AdminDbIntegrityCheckData(BaseModel): + name: str + count: int + severity: str + message: str + + model_config = ConfigDict(extra="forbid") + + +class AdminDbIntegrityData(BaseModel): + status: str + checked_at: datetime + failures: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + checks: list[AdminDbIntegrityCheckData] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class AdminDbIntegrityEnvelope(BaseModel): + data: AdminDbIntegrityData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminDbRepairJobData(BaseModel): + id: int + job_name: str + requested_by: str | None + dry_run: bool + status: str + scope: dict[str, Any] = Field(default_factory=dict) + summary: dict[str, Any] = Field(default_factory=dict) + error_text: str | None + started_at: datetime | None + finished_at: datetime | None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(extra="forbid") + + +class AdminDbRepairJobsData(BaseModel): + jobs: list[AdminDbRepairJobData] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class AdminDbRepairJobsEnvelope(BaseModel): + data: AdminDbRepairJobsData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueItemData(BaseModel): + publication_id: int + title: str + display_identifier: DisplayIdentifierData | None = None + pdf_url: str | None + status: str + attempt_count: int + last_failure_reason: str | None + last_failure_detail: str | None + last_source: str | None + requested_by_user_id: int | None + requested_by_email: str | None + queued_at: datetime | None + last_attempt_at: datetime | None + resolved_at: datetime | None + updated_at: datetime + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueData(BaseModel): + items: list[AdminPdfQueueItemData] = Field(default_factory=list) + total_count: int = 0 + page: int = 1 + page_size: int = 100 + has_next: bool = False + has_prev: bool = False + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueEnvelope(BaseModel): + data: AdminPdfQueueData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueRequeueData(BaseModel): + publication_id: int + queued: bool + status: str + message: str + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueRequeueEnvelope(BaseModel): + data: AdminPdfQueueRequeueData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueBulkEnqueueData(BaseModel): + requested_count: int + queued_count: int + message: str + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueBulkEnqueueEnvelope(BaseModel): + data: AdminPdfQueueBulkEnqueueData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminRepairPublicationLinksRequest(BaseModel): + scope_mode: Literal["single_user", "all_users"] = "single_user" + user_id: int | None = Field(default=None, ge=1) + scholar_profile_ids: list[int] = Field(default_factory=list, max_length=200) + dry_run: bool = True + gc_orphan_publications: bool = False + requested_by: str | None = None + confirmation_text: str | None = None + + model_config = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def validate_scope(self) -> AdminRepairPublicationLinksRequest: + if self.scope_mode == "single_user" and self.user_id is None: + raise ValueError("user_id is required when scope_mode=single_user.") + if self.scope_mode == "all_users" and self.user_id is not None: + raise ValueError("user_id must be omitted when scope_mode=all_users.") + if not self.dry_run and self.scope_mode == "all_users": + expected = "REPAIR ALL USERS" + provided = (self.confirmation_text or "").strip() + if provided != expected: + raise ValueError("confirmation_text must equal 'REPAIR ALL USERS' when applying a repair to all users.") + return self + + +class AdminRepairPublicationLinksResultData(BaseModel): + job_id: int + status: str + scope: dict[str, Any] = Field(default_factory=dict) + summary: dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(extra="forbid") + + +class AdminRepairPublicationLinksEnvelope(BaseModel): + data: AdminRepairPublicationLinksResultData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminNearDuplicateClusterMemberData(BaseModel): + publication_id: int + title: str + year: int | None + citation_count: int + + model_config = ConfigDict(extra="forbid") + + +class AdminNearDuplicateClusterData(BaseModel): + cluster_key: str + winner_publication_id: int + member_count: int + similarity_score: float = Field(ge=0.0, le=1.0) + members: list[AdminNearDuplicateClusterMemberData] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class AdminRepairPublicationNearDuplicatesRequest(BaseModel): + dry_run: bool = True + similarity_threshold: float = Field(default=0.78, ge=0.5, le=1.0) + min_shared_tokens: int = Field(default=3, ge=1, le=8) + max_year_delta: int = Field(default=1, ge=0, le=5) + max_clusters: int = Field(default=25, ge=1, le=200) + selected_cluster_keys: list[str] = Field(default_factory=list, max_length=200) + requested_by: str | None = None + confirmation_text: str | None = None + + model_config = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def validate_apply_mode(self) -> AdminRepairPublicationNearDuplicatesRequest: + if self.dry_run: + return self + if not self.selected_cluster_keys: + raise ValueError("selected_cluster_keys is required when dry_run=false.") + expected = "MERGE SELECTED DUPLICATES" + provided = (self.confirmation_text or "").strip() + if provided != expected: + raise ValueError( + "confirmation_text must equal 'MERGE SELECTED DUPLICATES' when applying near-duplicate merges." + ) + return self + + +class AdminRepairPublicationNearDuplicatesResultData(BaseModel): + job_id: int + status: str + scope: dict[str, Any] = Field(default_factory=dict) + summary: dict[str, Any] = Field(default_factory=dict) + clusters: list[AdminNearDuplicateClusterData] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class AdminRepairPublicationNearDuplicatesEnvelope(BaseModel): + data: AdminRepairPublicationNearDuplicatesResultData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/auth.py b/app/api/schemas/auth.py new file mode 100644 index 0000000..14efa57 --- /dev/null +++ b/app/api/schemas/auth.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from app.api.schemas.common import ApiMeta + + +class SessionUserData(BaseModel): + id: int + email: str + is_admin: bool + is_active: bool + + model_config = ConfigDict(extra="forbid") + + +class AuthMeData(BaseModel): + authenticated: bool + csrf_token: str + user: SessionUserData + + model_config = ConfigDict(extra="forbid") + + +class AuthMeEnvelope(BaseModel): + data: AuthMeData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class CsrfBootstrapData(BaseModel): + csrf_token: str + authenticated: bool + + model_config = ConfigDict(extra="forbid") + + +class CsrfBootstrapEnvelope(BaseModel): + data: CsrfBootstrapData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class LoginRequest(BaseModel): + email: str = Field(max_length=254) + password: str = Field(min_length=8, max_length=128) + + model_config = ConfigDict(extra="forbid") + + +class LoginData(BaseModel): + authenticated: bool + csrf_token: str + user: SessionUserData + + model_config = ConfigDict(extra="forbid") + + +class LoginEnvelope(BaseModel): + data: LoginData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class ChangePasswordRequest(BaseModel): + current_password: str = Field(min_length=8, max_length=128) + new_password: str = Field(min_length=8, max_length=128) + confirm_password: str = Field(min_length=8, max_length=128) + + model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/common.py b/app/api/schemas/common.py new file mode 100644 index 0000000..cc0805f --- /dev/null +++ b/app/api/schemas/common.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class ApiMeta(BaseModel): + request_id: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class ApiErrorData(BaseModel): + code: str + message: str + details: Any | None = None + + model_config = ConfigDict(extra="forbid") + + +class ApiErrorEnvelope(BaseModel): + error: ApiErrorData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class MessageData(BaseModel): + message: str + + model_config = ConfigDict(extra="forbid") + + +class MessageEnvelope(BaseModel): + data: MessageData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/publications.py b/app/api/schemas/publications.py new file mode 100644 index 0000000..6ae7c2e --- /dev/null +++ b/app/api/schemas/publications.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.api.schemas.common import ApiMeta + + +class DisplayIdentifierData(BaseModel): + kind: str + value: str + label: str + url: str | None + confidence_score: float = Field(ge=0.0, le=1.0) + + model_config = ConfigDict(extra="forbid") + + +class PublicationItemData(BaseModel): + publication_id: int + scholar_profile_id: int + scholar_label: str + title: str + year: int | None + citation_count: int + venue_text: str | None + pub_url: str | None + display_identifier: DisplayIdentifierData | None = None + pdf_url: str | None + pdf_status: str = "untracked" + pdf_attempt_count: int = 0 + pdf_failure_reason: str | None = None + pdf_failure_detail: str | None = None + is_read: bool + is_favorite: bool = False + first_seen_at: datetime + is_new_in_latest_run: bool + + model_config = ConfigDict(extra="forbid") + + +class PublicationsListData(BaseModel): + mode: str + favorite_only: bool = False + selected_scholar_profile_id: int | None + unread_count: int + favorites_count: int + latest_count: int + new_count: int + total_count: int + page: int + page_size: int + snapshot: str + has_next: bool = False + has_prev: bool = False + publications: list[PublicationItemData] + + model_config = ConfigDict(extra="forbid") + + +class PublicationsListEnvelope(BaseModel): + data: PublicationsListData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class MarkAllReadData(BaseModel): + message: str + updated_count: int + + model_config = ConfigDict(extra="forbid") + + +class MarkAllReadEnvelope(BaseModel): + data: MarkAllReadData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class PublicationSelectionItem(BaseModel): + scholar_profile_id: int + publication_id: int + + model_config = ConfigDict(extra="forbid") + + +class MarkSelectedReadRequest(BaseModel): + selections: list[PublicationSelectionItem] = Field(min_length=1, max_length=500) + + model_config = ConfigDict(extra="forbid") + + +class MarkSelectedReadData(BaseModel): + message: str + requested_count: int + updated_count: int + + model_config = ConfigDict(extra="forbid") + + +class MarkSelectedReadEnvelope(BaseModel): + data: MarkSelectedReadData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class RetryPublicationPdfRequest(BaseModel): + scholar_profile_id: int = Field(ge=1) + + model_config = ConfigDict(extra="forbid") + + +class RetryPublicationPdfData(BaseModel): + message: str + queued: bool + resolved_pdf: bool + publication: PublicationItemData + + model_config = ConfigDict(extra="forbid") + + +class RetryPublicationPdfEnvelope(BaseModel): + data: RetryPublicationPdfData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class TogglePublicationFavoriteRequest(BaseModel): + scholar_profile_id: int = Field(ge=1) + is_favorite: bool + + model_config = ConfigDict(extra="forbid") + + +class TogglePublicationFavoriteData(BaseModel): + message: str + publication: PublicationItemData + + model_config = ConfigDict(extra="forbid") + + +class TogglePublicationFavoriteEnvelope(BaseModel): + data: TogglePublicationFavoriteData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/runs.py b/app/api/schemas/runs.py new file mode 100644 index 0000000..e9e7791 --- /dev/null +++ b/app/api/schemas/runs.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.api.schemas.common import ApiMeta + + +class RunListItemData(BaseModel): + id: int + trigger_type: str + status: str + start_dt: datetime + end_dt: datetime | None + scholar_count: int + new_publication_count: int + failed_count: int + partial_count: int + + model_config = ConfigDict(extra="forbid") + + +class ScrapeSafetyCountersData(BaseModel): + consecutive_blocked_runs: int = 0 + consecutive_network_runs: int = 0 + cooldown_entry_count: int = 0 + blocked_start_count: int = 0 + last_blocked_failure_count: int = 0 + last_network_failure_count: int = 0 + last_evaluated_run_id: int | None = None + + model_config = ConfigDict(extra="forbid") + + +class ScrapeSafetyStateData(BaseModel): + cooldown_active: bool + cooldown_reason: str | None = None + cooldown_reason_label: str | None = None + cooldown_until: datetime | None = None + cooldown_remaining_seconds: int = 0 + recommended_action: str | None = None + counters: ScrapeSafetyCountersData = Field(default_factory=ScrapeSafetyCountersData) + + model_config = ConfigDict(extra="forbid") + + +class RunsListData(BaseModel): + runs: list[RunListItemData] + safety_state: ScrapeSafetyStateData + + model_config = ConfigDict(extra="forbid") + + +class RunsListEnvelope(BaseModel): + data: RunsListData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class RunSummaryData(BaseModel): + succeeded_count: int + failed_count: int + partial_count: int + failed_state_counts: dict[str, int] = Field(default_factory=dict) + failed_reason_counts: dict[str, int] = Field(default_factory=dict) + scrape_failure_counts: dict[str, int] = Field(default_factory=dict) + retry_counts: dict[str, int] = Field(default_factory=dict) + alert_thresholds: dict[str, int] = Field(default_factory=dict) + alert_flags: dict[str, bool] = Field(default_factory=dict) + + model_config = ConfigDict(extra="forbid") + + +class RunAttemptLogData(BaseModel): + attempt: int + cstart: int + state: str | None = None + state_reason: str | None = None + status_code: int | None = None + fetch_error: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class RunPageLogData(BaseModel): + page: int + cstart: int + state: str + state_reason: str | None = None + status_code: int | None = None + publication_count: int = 0 + attempt_count: int = 0 + has_show_more_button: bool = False + articles_range: str | None = None + warning_codes: list[str] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class RunDebugData(BaseModel): + status_code: int | None = None + final_url: str | None = None + fetch_error: str | None = None + body_sha256: str | None = None + body_length: int | None = None + has_show_more_button: bool | None = None + articles_range: str | None = None + state_reason: str | None = None + warning_codes: list[str] = Field(default_factory=list) + marker_counts_nonzero: dict[str, int] = Field(default_factory=dict) + page_logs: list[RunPageLogData] = Field(default_factory=list) + attempt_log: list[RunAttemptLogData] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class RunScholarResultData(BaseModel): + scholar_profile_id: int + scholar_id: str + state: str + state_reason: str | None = None + outcome: str + attempt_count: int = 0 + publication_count: int = 0 + start_cstart: int = 0 + continuation_cstart: int | None = None + continuation_enqueued: bool = False + continuation_cleared: bool = False + warnings: list[str] = Field(default_factory=list) + error: str | None = None + debug: RunDebugData | None = None + + model_config = ConfigDict(extra="forbid") + + +class RunDetailData(BaseModel): + run: RunListItemData + summary: RunSummaryData + scholar_results: list[RunScholarResultData] + safety_state: ScrapeSafetyStateData + + model_config = ConfigDict(extra="forbid") + + +class RunDetailEnvelope(BaseModel): + data: RunDetailData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class ManualRunData(BaseModel): + run_id: int + status: str + scholar_count: int + succeeded_count: int + failed_count: int + partial_count: int + new_publication_count: int + reused_existing_run: bool + idempotency_key: str | None = None + safety_state: ScrapeSafetyStateData + + model_config = ConfigDict(extra="forbid") + + +class ManualRunEnvelope(BaseModel): + data: ManualRunData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class QueueItemData(BaseModel): + id: int + scholar_profile_id: int + scholar_label: str + status: str + reason: str + dropped_reason: str | None + attempt_count: int + resume_cstart: int + next_attempt_dt: datetime | None + updated_at: datetime + last_error: str | None + last_run_id: int | None + + model_config = ConfigDict(extra="forbid") + + +class QueueListData(BaseModel): + queue_items: list[QueueItemData] + + model_config = ConfigDict(extra="forbid") + + +class QueueListEnvelope(BaseModel): + data: QueueListData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class QueueItemEnvelope(BaseModel): + data: QueueItemData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class QueueClearData(BaseModel): + queue_item_id: int + previous_status: str + status: str + message: str + + model_config = ConfigDict(extra="forbid") + + +class QueueClearEnvelope(BaseModel): + data: QueueClearData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/scholars.py b/app/api/schemas/scholars.py new file mode 100644 index 0000000..dda8329 --- /dev/null +++ b/app/api/schemas/scholars.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.api.schemas.common import ApiMeta + + +class ScholarItemData(BaseModel): + id: int + scholar_id: str + display_name: str | None + profile_image_url: str | None + profile_image_source: str + is_enabled: bool + baseline_completed: bool + last_run_dt: datetime | None + last_run_status: str | None + + model_config = ConfigDict(extra="forbid") + + +class ScholarsListData(BaseModel): + scholars: list[ScholarItemData] + + model_config = ConfigDict(extra="forbid") + + +class ScholarsListEnvelope(BaseModel): + data: ScholarsListData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class ScholarCreateRequest(BaseModel): + scholar_id: str + profile_image_url: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class ScholarEnvelope(BaseModel): + data: ScholarItemData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class ScholarSearchCandidateData(BaseModel): + scholar_id: str + display_name: str + affiliation: str | None + email_domain: str | None + cited_by_count: int | None + interests: list[str] = Field(default_factory=list) + profile_url: str + profile_image_url: str | None + + model_config = ConfigDict(extra="forbid") + + +class ScholarSearchData(BaseModel): + query: str + state: str + state_reason: str + action_hint: str | None = None + candidates: list[ScholarSearchCandidateData] + warnings: list[str] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class ScholarSearchEnvelope(BaseModel): + data: ScholarSearchData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class ScholarImageUrlUpdateRequest(BaseModel): + image_url: str + + model_config = ConfigDict(extra="forbid") + + +class ScholarExportItemData(BaseModel): + scholar_id: str + display_name: str | None = None + is_enabled: bool = True + profile_image_override_url: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class PublicationExportItemData(BaseModel): + scholar_id: str + cluster_id: str | None = None + fingerprint_sha256: str | None = None + title: str + year: int | None = None + citation_count: int = 0 + author_text: str | None = None + venue_text: str | None = None + pub_url: str | None = None + pdf_url: str | None = None + is_read: bool = False + + model_config = ConfigDict(extra="forbid") + + +class DataExportData(BaseModel): + schema_version: int + exported_at: str + scholars: list[ScholarExportItemData] + publications: list[PublicationExportItemData] + + model_config = ConfigDict(extra="forbid") + + +class DataExportEnvelope(BaseModel): + data: DataExportData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class DataImportRequest(BaseModel): + schema_version: int | None = None + scholars: list[ScholarExportItemData] = Field(default_factory=list) + publications: list[PublicationExportItemData] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class DataImportResultData(BaseModel): + scholars_created: int + scholars_updated: int + publications_created: int + publications_updated: int + links_created: int + links_updated: int + skipped_records: int + + model_config = ConfigDict(extra="forbid") + + +class DataImportEnvelope(BaseModel): + data: DataImportResultData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") diff --git a/app/api/schemas/settings.py b/app/api/schemas/settings.py new file mode 100644 index 0000000..cc06ee8 --- /dev/null +++ b/app/api/schemas/settings.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from app.api.schemas.common import ApiMeta +from app.api.schemas.runs import ScrapeSafetyStateData + + +class SettingsPolicyData(BaseModel): + min_run_interval_minutes: int + min_request_delay_seconds: int + automation_allowed: bool + manual_run_allowed: bool + blocked_failure_threshold: int + network_failure_threshold: int + cooldown_blocked_seconds: int + cooldown_network_seconds: int + + model_config = ConfigDict(extra="forbid") + + +class SettingsData(BaseModel): + auto_run_enabled: bool + run_interval_minutes: int + request_delay_seconds: int + nav_visible_pages: list[str] + policy: SettingsPolicyData + safety_state: ScrapeSafetyStateData + + openalex_api_key: str | None = None + crossref_api_token: str | None = None + crossref_api_mailto: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class SettingsEnvelope(BaseModel): + data: SettingsData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class SettingsUpdateRequest(BaseModel): + auto_run_enabled: bool + run_interval_minutes: int + request_delay_seconds: int + nav_visible_pages: list[str] | None = None + + openalex_api_key: str | None = None + crossref_api_token: str | None = None + crossref_api_mailto: str | None = None + + model_config = ConfigDict(extra="forbid") diff --git a/app/auth/__init__.py b/app/auth/__init__.py index 79644a4..b2c2619 100644 --- a/app/auth/__init__.py +++ b/app/auth/__init__.py @@ -1,2 +1 @@ """Authentication package for scholarr.""" - diff --git a/app/auth/deps.py b/app/auth/deps.py index 50424ae..ec7613c 100644 --- a/app/auth/deps.py +++ b/app/auth/deps.py @@ -24,4 +24,3 @@ def get_login_rate_limiter() -> SlidingWindowRateLimiter: max_attempts=settings.login_rate_limit_attempts, window_seconds=settings.login_rate_limit_window_seconds, ) - diff --git a/app/auth/runtime.py b/app/auth/runtime.py index 777e799..267325a 100644 --- a/app/auth/runtime.py +++ b/app/auth/runtime.py @@ -7,8 +7,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.session import clear_session_user, get_session_user, set_session_user from app.db.models import User +from app.logging_utils import structured_log from app.security.csrf import CSRF_SESSION_KEY -from app.services.domains.users import application as user_service +from app.services.users import application as user_service logger = logging.getLogger(__name__) @@ -28,13 +29,7 @@ async def get_authenticated_user( user = await user_service.get_user_by_id(db_session, session_user.id) if user is None or not user.is_active: - logger.info( - "auth.session_invalidated", - extra={ - "event": "auth.session_invalidated", - "session_user_id": session_user.id, - }, - ) + structured_log(logger, "info", "auth.session_invalidated", session_user_id=session_user.id) invalidate_session(request) return None diff --git a/app/auth/security.py b/app/auth/security.py index f89b122..befcd7c 100644 --- a/app/auth/security.py +++ b/app/auth/security.py @@ -16,4 +16,3 @@ class PasswordService: return bool(self._hasher.verify(password_hash, password)) except (InvalidHashError, VerificationError): return False - diff --git a/app/auth/service.py b/app/auth/service.py index 9772358..e13e3fe 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -21,9 +21,7 @@ class AuthService: normalized_email = email.strip().lower() if not normalized_email or not password: return None - result = await db_session.execute( - select(User).where(User.email == normalized_email) - ) + result = await db_session.execute(select(User).where(User.email == normalized_email)) user = result.scalar_one_or_none() if user is None or not user.is_active: return None diff --git a/app/auth/session.py b/app/auth/session.py index 8c83197..cf52836 100644 --- a/app/auth/session.py +++ b/app/auth/session.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from starlette.requests import Request - SESSION_USER_ID_KEY = "auth_user_id" SESSION_USER_EMAIL_KEY = "auth_user_email" SESSION_USER_IS_ADMIN_KEY = "auth_user_is_admin" diff --git a/app/db/base.py b/app/db/base.py index 6fa2049..78cb78d 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -1,9 +1,8 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from sqlalchemy import MetaData, event from sqlalchemy.orm import DeclarativeBase - NAMING_CONVENTION = { "ix": "ix_%(column_0_label)s", "uq": "uq_%(table_name)s_%(column_0_name)s", @@ -21,7 +20,7 @@ class Base(DeclarativeBase): def _set_updated_at_before_update(_mapper, _connection, target) -> None: # Keep audit timestamps current for ORM-managed updates. if hasattr(target, "updated_at"): - target.updated_at = datetime.now(timezone.utc) + target.updated_at = datetime.now(UTC) metadata = Base.metadata diff --git a/app/db/models.py b/app/db/models.py index 43b44c2..66c6ff8 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -8,6 +8,7 @@ from sqlalchemy import ( CheckConstraint, DateTime, Enum, + Float, ForeignKey, Index, Integer, @@ -30,9 +31,11 @@ class RunTriggerType(StrEnum): class RunStatus(StrEnum): RUNNING = "running" + RESOLVING = "resolving" SUCCESS = "success" PARTIAL_FAILURE = "partial_failure" FAILED = "failed" + CANCELED = "canceled" class QueueItemStatus(StrEnum): @@ -59,18 +62,10 @@ class User(Base): id: Mapped[int] = mapped_column(primary_key=True) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) - is_active: Mapped[bool] = mapped_column( - Boolean, nullable=False, server_default=text("true") - ) - is_admin: Mapped[bool] = mapped_column( - Boolean, nullable=False, server_default=text("false") - ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class UserSetting(Base): @@ -86,18 +81,10 @@ class UserSetting(Base): ), ) - user_id: Mapped[int] = mapped_column( - ForeignKey("users.id", ondelete="CASCADE"), primary_key=True - ) - auto_run_enabled: Mapped[bool] = mapped_column( - Boolean, nullable=False, server_default=text("false") - ) - run_interval_minutes: Mapped[int] = mapped_column( - Integer, nullable=False, server_default=text("1440") - ) - request_delay_seconds: Mapped[int] = mapped_column( - Integer, nullable=False, server_default=text("10") - ) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), primary_key=True) + auto_run_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + run_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1440")) + request_delay_seconds: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("10")) nav_visible_pages: Mapped[list[str]] = mapped_column( JSONB, nullable=False, @@ -112,12 +99,13 @@ class UserSetting(Base): ) scrape_cooldown_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) scrape_cooldown_reason: Mapped[str | None] = mapped_column(String(64)) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) + + openalex_api_key: Mapped[str | None] = mapped_column(String(255)) + crossref_api_token: Mapped[str | None] = mapped_column(String(255)) + crossref_api_mailto: Mapped[str | None] = mapped_column(String(255)) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class ScholarProfile(Base): @@ -128,9 +116,7 @@ class ScholarProfile(Base): ) id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) scholar_id: Mapped[str] = mapped_column(String(64), nullable=False) display_name: Mapped[str | None] = mapped_column(String(255)) profile_image_url: Mapped[str | None] = mapped_column(Text) @@ -138,69 +124,47 @@ class ScholarProfile(Base): profile_image_upload_path: Mapped[str | None] = mapped_column(Text) last_initial_page_fingerprint_sha256: Mapped[str | None] = mapped_column(String(64)) last_initial_page_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - is_enabled: Mapped[bool] = mapped_column( - Boolean, nullable=False, server_default=text("true") - ) - baseline_completed: Mapped[bool] = mapped_column( - Boolean, nullable=False, server_default=text("false") - ) + is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + baseline_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) last_run_dt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_run_status: Mapped[RunStatus | None] = mapped_column( RUN_STATUS_DB_ENUM, ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class CrawlRun(Base): __tablename__ = "crawl_runs" __table_args__ = ( Index("ix_crawl_runs_user_start", "user_id", "start_dt"), + Index( + "uq_crawl_runs_user_active", + "user_id", + unique=True, + postgresql_where=text("status IN ('running'::run_status, 'resolving'::run_status)"), + ), Index( "uq_crawl_runs_user_manual_idempotency_key", "user_id", "idempotency_key", unique=True, - postgresql_where=text( - "idempotency_key IS NOT NULL AND trigger_type = 'manual'::run_trigger_type" - ), + postgresql_where=text("idempotency_key IS NOT NULL AND trigger_type = 'manual'::run_trigger_type"), ), ) id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - trigger_type: Mapped[RunTriggerType] = mapped_column( - RUN_TRIGGER_TYPE_DB_ENUM, nullable=False - ) - status: Mapped[RunStatus] = mapped_column( - RUN_STATUS_DB_ENUM, nullable=False - ) - start_dt: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + trigger_type: Mapped[RunTriggerType] = mapped_column(RUN_TRIGGER_TYPE_DB_ENUM, nullable=False) + status: Mapped[RunStatus] = mapped_column(RUN_STATUS_DB_ENUM, nullable=False) + start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) end_dt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - scholar_count: Mapped[int] = mapped_column( - Integer, nullable=False, server_default=text("0") - ) - new_pub_count: Mapped[int] = mapped_column( - Integer, nullable=False, server_default=text("0") - ) + scholar_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + new_pub_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) idempotency_key: Mapped[str | None] = mapped_column(String(128)) - error_log: Mapped[dict] = mapped_column( - JSONB, nullable=False, server_default=text("'{}'::jsonb") - ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) + error_log: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class Publication(Base): @@ -221,20 +185,59 @@ class Publication(Base): title_raw: Mapped[str] = mapped_column(Text, nullable=False) title_normalized: Mapped[str] = mapped_column(Text, nullable=False) year: Mapped[int | None] = mapped_column(Integer) - citation_count: Mapped[int] = mapped_column( - Integer, nullable=False, server_default=text("0") - ) + citation_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) author_text: Mapped[str | None] = mapped_column(Text) venue_text: Mapped[str | None] = mapped_column(Text) pub_url: Mapped[str | None] = mapped_column(Text) - doi: Mapped[str | None] = mapped_column(String(255)) pdf_url: Mapped[str | None] = mapped_column(Text) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() + canonical_title_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + openalex_enriched: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + openalex_last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class PublicationIdentifier(Base): + __tablename__ = "publication_identifiers" + __table_args__ = ( + UniqueConstraint( + "publication_id", + "kind", + "value_normalized", + name="uq_publication_identifiers_publication_kind_value", + ), + CheckConstraint( + "confidence_score >= 0 AND confidence_score <= 1", + name="publication_identifiers_confidence_score_range", + ), + Index( + "ix_publication_identifiers_kind_value", + "kind", + "value_normalized", + ), + Index( + "ix_publication_identifiers_publication_id", + "publication_id", + ), ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() + + id: Mapped[int] = mapped_column(primary_key=True) + publication_id: Mapped[int] = mapped_column( + ForeignKey("publications.id", ondelete="CASCADE"), + nullable=False, ) + kind: Mapped[str] = mapped_column(String(32), nullable=False) + value_raw: Mapped[str] = mapped_column(Text, nullable=False) + value_normalized: Mapped[str] = mapped_column(String(255), nullable=False) + source: Mapped[str] = mapped_column(String(64), nullable=False) + confidence_score: Mapped[float] = mapped_column( + Float, + nullable=False, + server_default=text("0"), + ) + evidence_url: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class PublicationPdfJob(Base): @@ -272,12 +275,8 @@ class PublicationPdfJob(Base): last_requested_by_user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class PublicationPdfJobEvent(Base): @@ -301,9 +300,7 @@ class PublicationPdfJobEvent(Base): source: Mapped[str | None] = mapped_column(String(32)) failure_reason: Mapped[str | None] = mapped_column(String(64)) message: Mapped[str | None] = mapped_column(Text) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class ScholarPublication(Base): @@ -321,18 +318,10 @@ class ScholarPublication(Base): ForeignKey("publications.id", ondelete="CASCADE"), primary_key=True, ) - is_read: Mapped[bool] = mapped_column( - Boolean, nullable=False, server_default=text("false") - ) - is_favorite: Mapped[bool] = mapped_column( - Boolean, nullable=False, server_default=text("false") - ) - first_seen_run_id: Mapped[int | None] = mapped_column( - ForeignKey("crawl_runs.id", ondelete="SET NULL") - ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) + is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + is_favorite: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + first_seen_run_id: Mapped[int | None] = mapped_column(ForeignKey("crawl_runs.id", ondelete="SET NULL")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class IngestionQueueItem(Base): @@ -387,12 +376,8 @@ class IngestionQueueItem(Base): last_error: Mapped[str | None] = mapped_column(Text) dropped_reason: Mapped[str | None] = mapped_column(String(128)) dropped_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class DataRepairJob(Base): @@ -432,12 +417,8 @@ class DataRepairJob(Base): error_text: Mapped[str | None] = mapped_column(Text) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class AuthorSearchRuntimeState(Base): @@ -461,12 +442,38 @@ class AuthorSearchRuntimeState(Base): nullable=False, server_default=text("false"), ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class ArxivRuntimeState(Base): + __tablename__ = "arxiv_runtime_state" + + state_key: Mapped[str] = mapped_column(String(64), primary_key=True) + next_allowed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + cooldown_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class ArxivQueryCacheEntry(Base): + __tablename__ = "arxiv_query_cache_entries" + __table_args__ = ( + Index("ix_arxiv_query_cache_expires_at", "expires_at"), + Index("ix_arxiv_query_cache_cached_at", "cached_at"), ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() + + query_fingerprint: Mapped[str] = mapped_column(String(64), primary_key=True) + payload: Mapped[dict] = mapped_column( + JSONB, + nullable=False, ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + cached_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class AuthorSearchCacheEntry(Base): @@ -485,9 +492,5 @@ class AuthorSearchCacheEntry(Base): DateTime(timezone=True), nullable=False, ) - cached_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() - ) + cached_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/app/db/session.py b/app/db/session.py index c05266e..59a1745 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -1,6 +1,6 @@ -from collections.abc import AsyncIterator import logging import os +from collections.abc import AsyncIterator from sqlalchemy import text from sqlalchemy.ext.asyncio import ( @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import ( ) from sqlalchemy.pool import NullPool +from app.logging_utils import structured_log from app.settings import settings logger = logging.getLogger(__name__) @@ -30,13 +31,12 @@ def _normalized_pool_mode(raw_mode: str) -> str: return "queue" if mode in {"null", "queue"}: return mode - logger.warning( + structured_log( + logger, + "warning", "db.invalid_pool_mode_fallback", - extra={ - "event": "db.invalid_pool_mode_fallback", - "database_pool_mode": raw_mode, - "fallback_mode": "queue", - }, + database_pool_mode=raw_mode, + fallback_mode="queue", ) return "queue" @@ -56,12 +56,11 @@ def get_engine() -> AsyncEngine: engine_kwargs["pool_timeout"] = max(1, int(settings.database_pool_timeout_seconds)) _engine = create_async_engine(settings.database_url, **engine_kwargs) - logger.info( + structured_log( + logger, + "info", "db.engine_initialized", - extra={ - "event": "db.engine_initialized", - "pool_mode": pool_mode, - }, + pool_mode=pool_mode, ) return _engine @@ -86,7 +85,7 @@ async def check_database() -> bool: result = await conn.execute(text("SELECT 1")) return result.scalar_one() == 1 except Exception: - logger.exception("db.healthcheck_failed", extra={"event": "db.healthcheck_failed"}) + structured_log(logger, "exception", "db.healthcheck_failed") return False @@ -94,6 +93,6 @@ async def close_engine() -> None: global _engine, _session_factory if _engine is not None: await _engine.dispose() - logger.info("db.engine_disposed", extra={"event": "db.engine_disposed"}) + structured_log(logger, "info", "db.engine_disposed") _engine = None _session_factory = None diff --git a/app/http/middleware.py b/app/http/middleware.py index ebe72f8..65607ed 100644 --- a/app/http/middleware.py +++ b/app/http/middleware.py @@ -1,14 +1,15 @@ from __future__ import annotations -from secrets import token_urlsafe import logging import time +from secrets import token_urlsafe from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import Response from app.logging_context import set_request_id +from app.logging_utils import structured_log REQUEST_ID_HEADER = "X-Request-ID" DEFAULT_PERMISSIONS_POLICY = ( @@ -61,42 +62,39 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): start = time.perf_counter() should_log = self._log_requests and not self._is_skipped_path(request.url.path) if should_log: - logger.debug( + structured_log( + logger, + "debug", "request.started", - extra={ - "event": "request.started", - "method": request.method, - "path": request.url.path, - }, + method=request.method, + path=request.url.path, ) try: response = await call_next(request) except Exception: duration_ms = int((time.perf_counter() - start) * 1000) - logger.exception( + structured_log( + logger, + "exception", "request.failed", - extra={ - "event": "request.failed", - "method": request.method, - "path": request.url.path, - "duration_ms": duration_ms, - }, + method=request.method, + path=request.url.path, + duration_ms=duration_ms, ) raise else: duration_ms = int((time.perf_counter() - start) * 1000) response.headers[REQUEST_ID_HEADER] = request_id if should_log: - logger.debug( + structured_log( + logger, + "debug", "request.completed", - extra={ - "event": "request.completed", - "method": request.method, - "path": request.url.path, - "status_code": response.status_code, - "duration_ms": duration_ms, - }, + method=request.method, + path=request.url.path, + status_code=response.status_code, + duration_ms=duration_ms, ) return response finally: @@ -170,11 +168,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): csp_policy = self._csp_policy_for_path(request.url.path) if self._csp_enabled and csp_policy: - csp_header = ( - "Content-Security-Policy-Report-Only" - if self._csp_report_only - else "Content-Security-Policy" - ) + csp_header = "Content-Security-Policy-Report-Only" if self._csp_report_only else "Content-Security-Policy" response.headers.setdefault(csp_header, csp_policy) hsts = self._strict_transport_security_value() diff --git a/app/logging_config.py b/app/logging_config.py index 22907be..805fcbf 100644 --- a/app/logging_config.py +++ b/app/logging_config.py @@ -1,9 +1,9 @@ from __future__ import annotations -from datetime import datetime, timezone import json import logging import sys +from datetime import UTC, datetime from typing import Any from app.logging_context import get_request_id @@ -102,12 +102,22 @@ class JsonLogFormatter(logging.Formatter): if key.lower() in self._redact_fields: return "[REDACTED]" if isinstance(value, dict): - return {nested_key: self._redact_value(nested_key, nested_value) for nested_key, nested_value in value.items()} + return { + nested_key: self._redact_value(nested_key, nested_value) for nested_key, nested_value in value.items() + } if isinstance(value, (list, tuple)): return [self._redact_value(key, item) for item in value] return value +_CONSOLE_SHORT_KEYS = { + "user_id": "user", + "scholar_id": "scholar", + "crawl_run_id": "run", + "run_id": "run", +} + + class ConsoleLogFormatter(logging.Formatter): def __init__(self, *, redact_fields: set[str]) -> None: super().__init__() @@ -140,7 +150,8 @@ class ConsoleLogFormatter(logging.Formatter): for key in sorted(payload.keys()): if key in {"timestamp", "level", "logger", "event", "exception"}: continue - parts.append(f"{key}={payload[key]}") + display_key = _CONSOLE_SHORT_KEYS.get(key, key) + parts.append(f"{display_key}={payload[key]}") if "exception" in payload: parts.append(f"exception={payload['exception']}") @@ -168,7 +179,7 @@ def _normalize_level(level: str) -> int: def _format_timestamp(created_ts: float) -> str: - dt = datetime.fromtimestamp(created_ts, tz=timezone.utc) + dt = datetime.fromtimestamp(created_ts, tz=UTC) return dt.strftime("%Y-%m-%d %H:%M:%SZ") diff --git a/app/logging_context.py b/app/logging_context.py index ee578fb..a45136b 100644 --- a/app/logging_context.py +++ b/app/logging_context.py @@ -2,7 +2,6 @@ from __future__ import annotations from contextvars import ContextVar - _request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None) @@ -12,4 +11,3 @@ def get_request_id() -> str | None: def set_request_id(value: str | None) -> None: _request_id_ctx.set(value) - diff --git a/app/logging_utils.py b/app/logging_utils.py new file mode 100644 index 0000000..e81628e --- /dev/null +++ b/app/logging_utils.py @@ -0,0 +1,29 @@ +"""Structured logging utility — eliminates boilerplate across domain services.""" + +from __future__ import annotations + +import logging +from typing import Any + + +def structured_log( + logger: logging.Logger, + level: str, + event: str, + /, + **fields: Any, +) -> None: + """Emit a structured log entry. + + The event name is passed as the log message. The JsonLogFormatter in + logging_config.py extracts it via record.getMessage() when no explicit + 'event' key exists in extra — so we do NOT duplicate it. + + Usage: + structured_log(logger, "info", "ingestion.run_started", user_id=1, scholar_count=5) + """ + fields.pop("metric_name", None) + fields.pop("metric_value", None) + + log_method = getattr(logger, level.lower()) + log_method(event, extra=fields) diff --git a/app/main.py b/app/main.py index 254eb4a..f8610e1 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,7 @@ from __future__ import annotations -from contextlib import asynccontextmanager import logging +from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException @@ -12,17 +12,16 @@ from starlette.middleware.sessions import SessionMiddleware from app.api.errors import register_api_exception_handlers from app.api.media import router as media_router from app.api.router import router as api_router -from app.api.runtime_deps import get_ingestion_service, get_scholar_source -from app.db.session import check_database -from app.db.session import close_engine +from app.db.session import check_database, close_engine from app.http.middleware import ( RequestLoggingMiddleware, SecurityHeadersMiddleware, parse_skip_paths, ) from app.logging_config import configure_logging, parse_redact_fields +from app.logging_utils import structured_log from app.security.csrf import CSRFMiddleware -from app.services.domains.ingestion.scheduler import SchedulerService +from app.services.ingestion.scheduler import SchedulerService from app.settings import settings logger = logging.getLogger(__name__) @@ -50,22 +49,58 @@ scheduler_service = SchedulerService( ) -def _log_startup_build_marker() -> None: - logger.info( - "app.startup_build_marker", - extra={ - "event": "app.startup_build_marker", - "build_marker": BUILD_MARKER, - "frontend_enabled": settings.frontend_enabled, - "scheduler_enabled": settings.scheduler_enabled, - "log_format": settings.log_format, - }, - ) - - @asynccontextmanager async def lifespan(_: FastAPI): - _log_startup_build_marker() + structured_log( + logger, + "info", + "app.startup_build_marker", + build_marker=BUILD_MARKER, + frontend_enabled=settings.frontend_enabled, + scheduler_enabled=settings.scheduler_enabled, + log_format=settings.log_format, + ) + + from sqlalchemy import text + + from app.db.session import get_session_factory + + try: + session_factory = get_session_factory() + async with session_factory() as session: + await session.execute( + text("UPDATE crawl_runs SET status = 'failed' WHERE status::text IN ('running', 'resolving')") + ) + await session.commit() + structured_log(logger, "info", "app.startup_orphaned_runs_cleaned") + except Exception as exc: + structured_log( + logger, + "error", + "app.startup_orphaned_runs_cleanup_failed", + error=str(exc), + ) + + try: + session_factory = get_session_factory() + async with session_factory() as session: + await session.execute( + text( + "UPDATE publication_pdf_jobs SET status = 'queued'" + " WHERE status = 'running'" + " AND (last_attempt_at IS NULL OR last_attempt_at < NOW() - INTERVAL '10 minutes')" + ) + ) + await session.commit() + structured_log(logger, "info", "app.startup_stuck_pdf_jobs_recovered") + except Exception as exc: + structured_log( + logger, + "error", + "app.startup_stuck_pdf_jobs_recovery_failed", + error=str(exc), + ) + await scheduler_service.start() yield await scheduler_service.stop() @@ -101,9 +136,7 @@ app.add_middleware( content_security_policy_report_only=settings.security_csp_report_only, strict_transport_security_enabled=settings.security_strict_transport_security_enabled, strict_transport_security_max_age=settings.security_strict_transport_security_max_age, - strict_transport_security_include_subdomains=( - settings.security_strict_transport_security_include_subdomains - ), + strict_transport_security_include_subdomains=(settings.security_strict_transport_security_include_subdomains), strict_transport_security_preload=settings.security_strict_transport_security_preload, ) app.include_router(api_router) diff --git a/app/security/__init__.py b/app/security/__init__.py index 4033692..3cf1e91 100644 --- a/app/security/__init__.py +++ b/app/security/__init__.py @@ -1,2 +1 @@ """Security middleware and helpers for scholarr.""" - diff --git a/app/security/csrf.py b/app/security/csrf.py index 70eecac..822be3b 100644 --- a/app/security/csrf.py +++ b/app/security/csrf.py @@ -6,9 +6,11 @@ from urllib.parse import parse_qs from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request -from starlette.responses import JSONResponse, PlainTextResponse, Response +from starlette.responses import PlainTextResponse, Response from starlette.types import Message +from app.api.responses import error_response +from app.logging_utils import structured_log CSRF_SESSION_KEY = "csrf_token" CSRF_FORM_FIELD = "csrf_token" @@ -36,13 +38,12 @@ class CSRFMiddleware(BaseHTTPMiddleware): session_token = request.session.get(CSRF_SESSION_KEY) if not session_token: - logger.warning( + structured_log( + logger, + "warning", "csrf.missing_session_token", - extra={ - "event": "csrf.missing_session_token", - "method": request.method, - "path": request.url.path, - }, + method=request.method, + path=request.url.path, ) return self._csrf_error_response( request, @@ -54,16 +55,15 @@ class CSRFMiddleware(BaseHTTPMiddleware): if request_token is None and self._is_form_payload(request): body = await request.body() request_token = self._token_from_form_body(request, body) - request._receive = self._build_receive(body) # type: ignore[attr-defined] + request._receive = self._build_receive(body) if not request_token or not compare_digest(str(session_token), str(request_token)): - logger.warning( + structured_log( + logger, + "warning", "csrf.invalid_token", - extra={ - "event": "csrf.invalid_token", - "method": request.method, - "path": request.url.path, - }, + method=request.method, + path=request.url.path, ) return self._csrf_error_response( request, @@ -89,7 +89,10 @@ class CSRFMiddleware(BaseHTTPMiddleware): content_type = request.headers.get("content-type", "") if not content_type.startswith("application/x-www-form-urlencoded"): return None - parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True) + try: + parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True) + except (UnicodeDecodeError, ValueError): + return None values = parsed.get(CSRF_FORM_FIELD) if not values: return None @@ -115,19 +118,11 @@ class CSRFMiddleware(BaseHTTPMiddleware): message: str, ) -> Response: if request.url.path.startswith("/api/"): - request_state = getattr(request, "state", None) - request_id = getattr(request_state, "request_id", None) if request_state else None - return JSONResponse( - { - "error": { - "code": code, - "message": message, - "details": None, - }, - "meta": { - "request_id": request_id, - }, - }, + return error_response( + request, status_code=403, + code=code, + message=message, + details=None, ) return PlainTextResponse(message, status_code=403) diff --git a/app/services/__init__.py b/app/services/__init__.py index 167f869..6093d3d 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1,2 +1 @@ """Service layer for scholarr application workflows.""" - diff --git a/app/services/arxiv/application.py b/app/services/arxiv/application.py new file mode 100644 index 0000000..ff53632 --- /dev/null +++ b/app/services/arxiv/application.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.services.arxiv.gateway import ( + build_arxiv_query, + get_arxiv_gateway, +) + +if TYPE_CHECKING: + from app.services.publications.types import PublicationListItem, UnreadPublicationItem + + +def _build_arxiv_query(title: str, author_surname: str | None) -> str | None: + return build_arxiv_query(title, author_surname) + + +async def discover_arxiv_id_for_publication( + *, + item: PublicationListItem | UnreadPublicationItem, + request_email: str | None = None, + timeout_seconds: float | None = None, +) -> str | None: + gateway = get_arxiv_gateway() + return await gateway.discover_arxiv_id_for_publication( + item=item, + request_email=request_email, + timeout_seconds=timeout_seconds, + ) diff --git a/app/services/arxiv/cache.py b/app/services/arxiv/cache.py new file mode 100644 index 0000000..1e9dac9 --- /dev/null +++ b/app/services/arxiv/cache.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import asdict +from datetime import UTC, datetime, timedelta +from typing import Any + +from sqlalchemy import delete, func, select + +from app.db.models import ArxivQueryCacheEntry +from app.db.session import get_session_factory +from app.services.arxiv.constants import ARXIV_CACHE_FINGERPRINT_VERSION +from app.services.arxiv.types import ArxivEntry, ArxivFeed, ArxivOpenSearchMeta + +_INFLIGHT_LOCK = asyncio.Lock() +_INFLIGHT_FEEDS: dict[str, asyncio.Future[ArxivFeed]] = {} + + +def build_query_fingerprint(*, params: Mapping[str, object]) -> str: + canonical = _canonical_cache_payload(params=params) + encoded = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + payload = f"{ARXIV_CACHE_FINGERPRINT_VERSION}:{encoded}" + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +async def get_cached_feed( + *, + query_fingerprint: str, + now_utc: datetime | None = None, +) -> ArxivFeed | None: + timestamp = _as_utc(now_utc) + session_factory = get_session_factory() + async with session_factory() as db_session, db_session.begin(): + result = await db_session.execute( + select(ArxivQueryCacheEntry).where(ArxivQueryCacheEntry.query_fingerprint == query_fingerprint) + ) + entry = result.scalar_one_or_none() + return await _validate_cached_entry(db_session, entry=entry, now_utc=timestamp) + + +async def set_cached_feed( + *, + query_fingerprint: str, + feed: ArxivFeed, + ttl_seconds: float, + max_entries: int, + now_utc: datetime | None = None, +) -> None: + timestamp = _as_utc(now_utc) + session_factory = get_session_factory() + async with session_factory() as db_session, db_session.begin(): + await _write_cached_entry( + db_session, + query_fingerprint=query_fingerprint, + feed=feed, + ttl_seconds=ttl_seconds, + max_entries=max_entries, + now_utc=timestamp, + ) + + +async def run_with_inflight_dedupe( + *, + query_fingerprint: str, + fetch_feed: Callable[[], Awaitable[ArxivFeed]], +) -> ArxivFeed: + future, is_owner = await _reserve_inflight_future(query_fingerprint=query_fingerprint) + if not is_owner: + return await asyncio.shield(future) + try: + result = await fetch_feed() + except Exception as exc: + _complete_future(future, error=exc) + raise + finally: + await _release_inflight_future(query_fingerprint=query_fingerprint, future=future) + _complete_future(future, result=result) + return result + + +def _canonical_cache_payload(*, params: Mapping[str, object]) -> dict[str, object]: + payload: dict[str, object] = {} + for key in sorted(params.keys()): + payload[str(key)] = _normalize_param_value(str(key), params[key]) + return payload + + +def _normalize_param_value(key: str, value: object) -> object: + if key == "search_query": + return _normalize_search_query(str(value or "")) + if key == "id_list": + return _normalize_id_list(str(value or "")) + if isinstance(value, str): + return " ".join(value.strip().split()) + if isinstance(value, (int, float, bool)) or value is None: + return value + return str(value).strip() + + +def _normalize_search_query(value: str) -> str: + return " ".join(value.strip().lower().split()) + + +def _normalize_id_list(value: str) -> str: + normalized = [item.strip().lower() for item in value.split(",") if item.strip()] + return ",".join(sorted(normalized)) + + +async def _validate_cached_entry( + db_session, + *, + entry: ArxivQueryCacheEntry | None, + now_utc: datetime, +) -> ArxivFeed | None: + if entry is None: + return None + if _as_utc(entry.expires_at) <= now_utc: + await db_session.delete(entry) + return None + parsed = _deserialize_feed(entry.payload) + if parsed is None: + await db_session.delete(entry) + return None + return parsed + + +async def _write_cached_entry( + db_session, + *, + query_fingerprint: str, + feed: ArxivFeed, + ttl_seconds: float, + max_entries: int, + now_utc: datetime, +) -> None: + ttl = max(float(ttl_seconds), 0.0) + result = await db_session.execute( + select(ArxivQueryCacheEntry).where(ArxivQueryCacheEntry.query_fingerprint == query_fingerprint) + ) + existing = result.scalar_one_or_none() + if ttl <= 0.0: + if existing is not None: + await db_session.delete(existing) + return + expires_at = now_utc + timedelta(seconds=ttl) + payload = _serialize_feed(feed) + if existing is None: + db_session.add( + ArxivQueryCacheEntry( + query_fingerprint=query_fingerprint, + payload=payload, + expires_at=expires_at, + cached_at=now_utc, + updated_at=now_utc, + ) + ) + else: + existing.payload = payload + existing.expires_at = expires_at + existing.cached_at = now_utc + existing.updated_at = now_utc + await _prune_cache_entries(db_session, now_utc=now_utc, max_entries=max_entries) + + +async def _prune_cache_entries( + db_session, + *, + now_utc: datetime, + max_entries: int, +) -> None: + await db_session.execute(delete(ArxivQueryCacheEntry).where(ArxivQueryCacheEntry.expires_at <= now_utc)) + bounded_max_entries = int(max_entries) + if bounded_max_entries <= 0: + return + count_result = await db_session.execute(select(func.count()).select_from(ArxivQueryCacheEntry)) + entry_count = int(count_result.scalar_one() or 0) + overflow = max(0, entry_count - bounded_max_entries) + if overflow <= 0: + return + stale_result = await db_session.execute( + select(ArxivQueryCacheEntry.query_fingerprint).order_by(ArxivQueryCacheEntry.cached_at.asc()).limit(overflow) + ) + stale_keys = [str(row[0]) for row in stale_result.all()] + if stale_keys: + await db_session.execute( + delete(ArxivQueryCacheEntry).where(ArxivQueryCacheEntry.query_fingerprint.in_(stale_keys)) + ) + + +def _serialize_feed(feed: ArxivFeed) -> dict[str, Any]: + return asdict(feed) + + +def _deserialize_feed(payload: object) -> ArxivFeed | None: + if not isinstance(payload, dict): + return None + entries_payload = payload.get("entries") + opensearch_payload = payload.get("opensearch") + if not isinstance(entries_payload, list): + return None + entries: list[ArxivEntry] = [] + for value in entries_payload: + entry = _deserialize_entry(value) + if entry is None: + return None + entries.append(entry) + opensearch = _deserialize_opensearch(opensearch_payload) + if opensearch is None: + return None + return ArxivFeed(entries=entries, opensearch=opensearch) + + +def _deserialize_entry(value: object) -> ArxivEntry | None: + if not isinstance(value, dict): + return None + try: + return ArxivEntry( + entry_id_url=str(value["entry_id_url"]), + arxiv_id=_as_optional_string(value.get("arxiv_id")), + title=str(value["title"]), + summary=str(value["summary"]), + published=_as_optional_string(value.get("published")), + updated=_as_optional_string(value.get("updated")), + authors=_as_string_list(value.get("authors")), + links=_as_string_list(value.get("links")), + categories=_as_string_list(value.get("categories")), + primary_category=_as_optional_string(value.get("primary_category")), + ) + except KeyError: + return None + + +def _deserialize_opensearch(value: object) -> ArxivOpenSearchMeta | None: + if not isinstance(value, dict): + return None + try: + return ArxivOpenSearchMeta( + total_results=int(value.get("total_results", 0)), + start_index=int(value.get("start_index", 0)), + items_per_page=int(value.get("items_per_page", 0)), + ) + except (TypeError, ValueError): + return None + + +def _as_optional_string(value: object) -> str | None: + if value is None: + return None + normalized = str(value).strip() + return normalized or None + + +def _as_string_list(value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value] + + +def _as_utc(value: datetime | None) -> datetime: + if value is None: + return datetime.now(UTC) + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value + + +async def _reserve_inflight_future( + *, + query_fingerprint: str, +) -> tuple[asyncio.Future[ArxivFeed], bool]: + async with _INFLIGHT_LOCK: + existing = _INFLIGHT_FEEDS.get(query_fingerprint) + if existing is not None: + return existing, False + loop = asyncio.get_running_loop() + created = loop.create_future() + created.add_done_callback(_consume_unretrieved_future_exception) + _INFLIGHT_FEEDS[query_fingerprint] = created + return created, True + + +async def _release_inflight_future( + *, + query_fingerprint: str, + future: asyncio.Future[ArxivFeed], +) -> None: + async with _INFLIGHT_LOCK: + current = _INFLIGHT_FEEDS.get(query_fingerprint) + if current is future: + _INFLIGHT_FEEDS.pop(query_fingerprint, None) + + +def _complete_future( + future: asyncio.Future[ArxivFeed], + *, + result: ArxivFeed | None = None, + error: Exception | None = None, +) -> None: + if future.done(): + return + if error is not None: + future.set_exception(error) + return + if result is None: + raise RuntimeError("in-flight future completion requires result or error") + future.set_result(result) + + +def _consume_unretrieved_future_exception(future: asyncio.Future[ArxivFeed]) -> None: + if future.cancelled(): + return + try: + _ = future.exception() + except Exception: + return diff --git a/app/services/arxiv/client.py b/app/services/arxiv/client.py new file mode 100644 index 0000000..41de63d --- /dev/null +++ b/app/services/arxiv/client.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable + +import httpx + +from app.logging_utils import structured_log +from app.services.arxiv.cache import ( + build_query_fingerprint, + get_cached_feed, + run_with_inflight_dedupe, + set_cached_feed, +) +from app.services.arxiv.constants import ( + ARXIV_SOURCE_PATH_LOOKUP_IDS, + ARXIV_SOURCE_PATH_SEARCH, + ARXIV_SOURCE_PATH_UNKNOWN, +) +from app.services.arxiv.errors import ArxivClientValidationError, ArxivRateLimitError +from app.services.arxiv.parser import parse_arxiv_feed +from app.services.arxiv.rate_limit import get_arxiv_cooldown_status, run_with_global_arxiv_limit +from app.services.arxiv.types import ArxivFeed +from app.settings import settings + +_ARXIV_API_URL = "https://export.arxiv.org/api/query" +_ARXIV_QUERY_START = 0 +_ARXIV_MAX_RESULTS_LIMIT = 30_000 +_ARXIV_SORT_BY_ALLOWED = {"relevance", "lastUpdatedDate", "submittedDate"} +_ARXIV_SORT_ORDER_ALLOWED = {"ascending", "descending"} +_FALLBACK_CONTACT_EMAIL = "unknown@example.com" + +ArxivRequestFn = Callable[..., Awaitable[httpx.Response]] +logger = logging.getLogger(__name__) + + +class ArxivClient: + def __init__( + self, + *, + request_fn: ArxivRequestFn | None = None, + cache_enabled: bool | None = None, + ) -> None: + self._request_fn = request_fn or _request_arxiv_feed + self._cache_enabled = _resolve_cache_enabled( + cache_enabled=cache_enabled, + request_fn=request_fn, + ) + self._cache_ttl_seconds = _cache_ttl_seconds() + self._cache_max_entries = _cache_max_entries() + + async def search( + self, + *, + query: str, + start: int = _ARXIV_QUERY_START, + max_results: int | None = None, + sort_by: str | None = None, + sort_order: str | None = None, + request_email: str | None = None, + timeout_seconds: float | None = None, + ) -> ArxivFeed: + params = _search_params( + query=query, + start=start, + max_results=max_results, + sort_by=sort_by, + sort_order=sort_order, + ) + return await self._fetch_feed( + params=params, + request_email=request_email, + timeout_seconds=timeout_seconds, + source_path=ARXIV_SOURCE_PATH_SEARCH, + ) + + async def lookup_ids( + self, + *, + id_list: list[str], + start: int = _ARXIV_QUERY_START, + max_results: int | None = None, + request_email: str | None = None, + timeout_seconds: float | None = None, + ) -> ArxivFeed: + params = _lookup_params(id_list=id_list, start=start, max_results=max_results) + return await self._fetch_feed( + params=params, + request_email=request_email, + timeout_seconds=timeout_seconds, + source_path=ARXIV_SOURCE_PATH_LOOKUP_IDS, + ) + + async def _fetch_feed( + self, + *, + params: dict[str, object], + request_email: str | None, + timeout_seconds: float | None, + source_path: str, + ) -> ArxivFeed: + query_fingerprint = build_query_fingerprint(params=params) + if self._cache_enabled: + cached = await get_cached_feed(query_fingerprint=query_fingerprint) + if cached is not None: + structured_log( + logger, "info", "arxiv.cache_hit", query_fingerprint=query_fingerprint, source_path=source_path + ) + return cached + structured_log( + logger, "info", "arxiv.cache_miss", query_fingerprint=query_fingerprint, source_path=source_path + ) + return await run_with_inflight_dedupe( + query_fingerprint=query_fingerprint, + fetch_feed=lambda: self._fetch_live_feed( + params=params, + request_email=request_email, + timeout_seconds=timeout_seconds, + query_fingerprint=query_fingerprint, + ), + ) + + async def _fetch_live_feed( + self, + *, + params: dict[str, object], + request_email: str | None, + timeout_seconds: float | None, + query_fingerprint: str, + ) -> ArxivFeed: + response = await self._request_fn( + params=params, + request_email=request_email, + timeout_seconds=timeout_seconds, + ) + response.raise_for_status() + feed = parse_arxiv_feed(response.text) + if self._cache_enabled: + await set_cached_feed( + query_fingerprint=query_fingerprint, + feed=feed, + ttl_seconds=self._cache_ttl_seconds, + max_entries=self._cache_max_entries, + ) + return feed + + +def _search_params( + *, + query: str, + start: int, + max_results: int | None, + sort_by: str | None, + sort_order: str | None, +) -> dict[str, object]: + clean_query = query.strip() + if not clean_query: + raise ArxivClientValidationError("search query must not be empty") + params: dict[str, object] = { + "search_query": clean_query, + "start": _validate_start(start), + "max_results": _validate_max_results(max_results), + } + if sort_by is not None: + params["sortBy"] = _validate_sort_by(sort_by) + if sort_order is not None: + params["sortOrder"] = _validate_sort_order(sort_order) + return params + + +def _lookup_params(*, id_list: list[str], start: int, max_results: int | None) -> dict[str, object]: + normalized_ids = [value.strip() for value in id_list if value and value.strip()] + if not normalized_ids: + raise ArxivClientValidationError("id_list must include at least one id") + return { + "id_list": ",".join(normalized_ids), + "start": _validate_start(start), + "max_results": _validate_max_results(max_results), + } + + +def _validate_start(value: int) -> int: + start = int(value) + if start < 0: + raise ArxivClientValidationError("start must be >= 0") + return start + + +def _validate_max_results(value: int | None) -> int: + if value is None: + default_value = int(settings.arxiv_default_max_results) + return max(default_value, 1) + parsed = int(value) + if parsed < 1: + raise ArxivClientValidationError("max_results must be >= 1") + if parsed > _ARXIV_MAX_RESULTS_LIMIT: + raise ArxivClientValidationError(f"max_results must be <= {_ARXIV_MAX_RESULTS_LIMIT}") + return parsed + + +def _validate_sort_by(value: str) -> str: + if value not in _ARXIV_SORT_BY_ALLOWED: + raise ArxivClientValidationError(f"sort_by must be one of: {sorted(_ARXIV_SORT_BY_ALLOWED)!r}") + return value + + +def _validate_sort_order(value: str) -> str: + if value not in _ARXIV_SORT_ORDER_ALLOWED: + raise ArxivClientValidationError(f"sort_order must be one of: {sorted(_ARXIV_SORT_ORDER_ALLOWED)!r}") + return value + + +async def _request_arxiv_feed( + *, + params: dict[str, object], + request_email: str | None, + timeout_seconds: float | None, +) -> httpx.Response: + source_path = _source_path_from_params(params) + cooldown_status = await get_arxiv_cooldown_status() + if cooldown_status.is_active: + structured_log( + logger, + "warning", + "arxiv.request_skipped_cooldown", + source_path=source_path, + cooldown_remaining_seconds=float(cooldown_status.remaining_seconds), + ) + raise ArxivRateLimitError(f"arXiv global cooldown active ({cooldown_status.remaining_seconds:.0f}s remaining)") + + async def _fetch() -> httpx.Response: + timeout_value = _timeout_seconds(timeout_seconds) + headers = {"User-Agent": f"scholar-scraper/1.0 (mailto:{_contact_email(request_email)})"} + async with httpx.AsyncClient(timeout=timeout_value, follow_redirects=True, headers=headers) as client: + return await client.get(_ARXIV_API_URL, params=params) # type: ignore[arg-type] + + return await run_with_global_arxiv_limit( + fetch=_fetch, + source_path=source_path, + ) + + +def _timeout_seconds(timeout_seconds: float | None) -> float: + if timeout_seconds is not None: + return max(float(timeout_seconds), 0.5) + return max(float(settings.arxiv_timeout_seconds), 0.5) + + +def _contact_email(request_email: str | None) -> str: + return request_email or settings.arxiv_mailto or settings.crossref_api_mailto or _FALLBACK_CONTACT_EMAIL + + +def _resolve_cache_enabled( + *, + cache_enabled: bool | None, + request_fn: ArxivRequestFn | None, +) -> bool: + if cache_enabled is not None: + return bool(cache_enabled) + if request_fn is not None: + return False + return _cache_ttl_seconds() > 0.0 + + +def _cache_ttl_seconds() -> float: + return max(float(settings.arxiv_cache_ttl_seconds), 0.0) + + +def _cache_max_entries() -> int: + return max(int(settings.arxiv_cache_max_entries), 0) + + +def _source_path_from_params(params: dict[str, object]) -> str: + if "search_query" in params: + return ARXIV_SOURCE_PATH_SEARCH + if "id_list" in params: + return ARXIV_SOURCE_PATH_LOOKUP_IDS + return ARXIV_SOURCE_PATH_UNKNOWN diff --git a/app/services/arxiv/constants.py b/app/services/arxiv/constants.py new file mode 100644 index 0000000..eab9293 --- /dev/null +++ b/app/services/arxiv/constants.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +ARXIV_RUNTIME_STATE_KEY = "global" +ARXIV_RATE_LIMIT_LOCK_NAMESPACE = 91_100 +ARXIV_RATE_LIMIT_LOCK_KEY = 1 +ARXIV_SOURCE_PATH_SEARCH = "search" +ARXIV_SOURCE_PATH_LOOKUP_IDS = "lookup_ids" +ARXIV_SOURCE_PATH_UNKNOWN = "unknown" +ARXIV_CACHE_FINGERPRINT_VERSION = "v1" +ARXIV_TITLE_TOKEN_MIN_LENGTH = 3 +ARXIV_TITLE_MIN_TOKENS = 3 +ARXIV_TITLE_MIN_ALPHA_TOKENS = 2 +ARXIV_STRONG_IDENTIFIER_CONFIDENCE = 0.9 diff --git a/app/services/arxiv/errors.py b/app/services/arxiv/errors.py new file mode 100644 index 0000000..3561f61 --- /dev/null +++ b/app/services/arxiv/errors.py @@ -0,0 +1,13 @@ +from __future__ import annotations + + +class ArxivRateLimitError(Exception): + """arXiv returned 429 or cooldown is active.""" + + +class ArxivClientValidationError(ValueError): + """arXiv client inputs are invalid.""" + + +class ArxivParseError(ValueError): + """arXiv API payload could not be parsed.""" diff --git a/app/services/arxiv/gateway.py b/app/services/arxiv/gateway.py new file mode 100644 index 0000000..b352894 --- /dev/null +++ b/app/services/arxiv/gateway.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import logging +import re +import unicodedata +from typing import TYPE_CHECKING, Protocol + +from app.logging_utils import structured_log +from app.services.arxiv.client import ArxivClient +from app.services.arxiv.errors import ArxivRateLimitError +from app.services.arxiv.types import ArxivFeed +from app.settings import settings + +if TYPE_CHECKING: + from app.services.publications.types import PublicationListItem, UnreadPublicationItem + +logger = logging.getLogger(__name__) + +_default_gateway: ArxivGateway | None = None +_MOJIBAKE_HINT_RE = re.compile(r"[ÃÂâ]") +_NON_ALNUM_RE = re.compile(r"[^\w\s]+", re.UNICODE) +_WHITESPACE_RE = re.compile(r"\s+") + + +class ArxivGateway(Protocol): + async def discover_arxiv_id_for_publication( + self, + *, + item: PublicationListItem | UnreadPublicationItem, + request_email: str | None = None, + timeout_seconds: float | None = None, + max_results: int | None = None, + ) -> str | None: ... + + +def build_arxiv_query(title: str, author_surname: str | None) -> str | None: + parts: list[str] = [] + if title: + clean_title = _normalize_query_title(title) + if clean_title: + parts.append(f'ti:"{clean_title}"') + if author_surname: + clean_author = _normalize_query_title(author_surname) + if clean_author: + parts.append(f'au:"{clean_author}"') + if not parts: + return None + return " AND ".join(parts) + + +def _normalize_query_title(value: str) -> str: + repaired = _repair_mojibake(value.strip()) + normalized = unicodedata.normalize("NFKC", repaired) + stripped = _NON_ALNUM_RE.sub(" ", _MOJIBAKE_HINT_RE.sub(" ", normalized)) + return _WHITESPACE_RE.sub(" ", stripped).strip() + + +def _repair_mojibake(value: str) -> str: + if not value or not _MOJIBAKE_HINT_RE.search(value): + return value + try: + repaired = value.encode("latin1").decode("utf-8") + except UnicodeError: + return value + return repaired if _mojibake_score(repaired) < _mojibake_score(value) else value + + +def _mojibake_score(value: str) -> int: + return len(_MOJIBAKE_HINT_RE.findall(value)) + + +def get_arxiv_gateway() -> ArxivGateway: + global _default_gateway + if _default_gateway is None: + _default_gateway = HttpArxivGateway() + return _default_gateway + + +def set_arxiv_gateway(gateway: ArxivGateway | None) -> ArxivGateway | None: + global _default_gateway + previous = _default_gateway + _default_gateway = gateway + return previous + + +class HttpArxivGateway: + def __init__(self, *, client: ArxivClient | None = None) -> None: + self._client = client or ArxivClient() + + async def discover_arxiv_id_for_publication( + self, + *, + item: PublicationListItem | UnreadPublicationItem, + request_email: str | None = None, + timeout_seconds: float | None = None, + max_results: int | None = None, + ) -> str | None: + if not settings.arxiv_enabled: + return None + query = _query_for_item(item) + if query is None: + return None + + try: + result = await self._client.search( + query=query, + start=0, + request_email=request_email, + timeout_seconds=timeout_seconds, + max_results=max_results, + ) + return _first_discovered_id(result) + except ArxivRateLimitError: + raise + except Exception as exc: + structured_log(logger, "debug", "arxiv.query_failed", error=str(exc)) + return None + + +def _query_for_item(item: PublicationListItem | UnreadPublicationItem) -> str | None: + title = (item.title or "").strip() + if not title: + return None + author_surname = _author_surname(item.scholar_label) + return build_arxiv_query(title, author_surname) + + +def _author_surname(scholar_label: str | None) -> str | None: + if not scholar_label: + return None + tokens = [token for token in scholar_label.strip().split() if token] + if not tokens: + return None + return tokens[-1].lower() + + +def _first_discovered_id(result: ArxivFeed) -> str | None: + for entry in result.entries: + if entry.arxiv_id: + structured_log(logger, "debug", "arxiv.id_discovered", arxiv_id=entry.arxiv_id) + return entry.arxiv_id + return None diff --git a/app/services/arxiv/guards.py b/app/services/arxiv/guards.py new file mode 100644 index 0000000..db88b3f --- /dev/null +++ b/app/services/arxiv/guards.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from app.services.arxiv.constants import ( + ARXIV_STRONG_IDENTIFIER_CONFIDENCE, + ARXIV_TITLE_MIN_ALPHA_TOKENS, + ARXIV_TITLE_MIN_TOKENS, + ARXIV_TITLE_TOKEN_MIN_LENGTH, +) +from app.services.doi.normalize import normalize_doi +from app.services.publication_identifiers.normalize import normalize_arxiv_id + +if TYPE_CHECKING: + from app.services.publications.types import PublicationListItem, UnreadPublicationItem + +_TITLE_TOKEN_RE = re.compile(r"[a-z0-9]+") + + +def arxiv_skip_reason_for_item( + *, + item: PublicationListItem | UnreadPublicationItem, + has_strong_doi: bool = False, + has_existing_arxiv: bool = False, +) -> str | None: + if has_existing_arxiv or _has_arxiv_identifier_evidence(item): + return "arxiv_identifier_present" + if has_strong_doi or _has_strong_doi_evidence(item): + return "strong_doi_present" + if not _title_passes_quality_guard(item.title): + return "title_quality_below_threshold" + return None + + +def _has_arxiv_identifier_evidence(item: PublicationListItem | UnreadPublicationItem) -> bool: + if _display_identifier_matches(item, expected_kind="arxiv"): + return True + return _has_normalized_identifier(item, normalizer=normalize_arxiv_id) + + +def _has_strong_doi_evidence(item: PublicationListItem | UnreadPublicationItem) -> bool: + if _display_identifier_matches(item, expected_kind="doi"): + return True + return _has_normalized_identifier(item, normalizer=normalize_doi) + + +def _display_identifier_matches( + item: PublicationListItem | UnreadPublicationItem, + *, + expected_kind: str, +) -> bool: + display = getattr(item, "display_identifier", None) + if display is None: + return False + if str(display.kind).lower() != expected_kind: + return False + return float(display.confidence_score) >= ARXIV_STRONG_IDENTIFIER_CONFIDENCE + + +def _has_normalized_identifier( + item: PublicationListItem | UnreadPublicationItem, + *, + normalizer, +) -> bool: + if normalizer(item.pub_url): + return True + return normalizer(item.pdf_url) is not None + + +def _title_passes_quality_guard(title: str | None) -> bool: + tokens = _normalized_tokens(title or "") + if len(tokens) < ARXIV_TITLE_MIN_TOKENS: + return False + alpha_tokens = [token for token in tokens if _is_alpha_token(token)] + return len(alpha_tokens) >= ARXIV_TITLE_MIN_ALPHA_TOKENS + + +def _normalized_tokens(value: str) -> list[str]: + return [token for token in _TITLE_TOKEN_RE.findall(value.lower()) if token] + + +def _is_alpha_token(token: str) -> bool: + if len(token) < ARXIV_TITLE_TOKEN_MIN_LENGTH: + return False + return any(char.isalpha() for char in token) diff --git a/app/services/arxiv/parser.py b/app/services/arxiv/parser.py new file mode 100644 index 0000000..f563493 --- /dev/null +++ b/app/services/arxiv/parser.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET + +from app.services.arxiv.errors import ArxivParseError +from app.services.arxiv.types import ArxivEntry, ArxivFeed, ArxivOpenSearchMeta +from app.services.publication_identifiers.normalize import normalize_arxiv_id + +_NAMESPACES = { + "atom": "http://www.w3.org/2005/Atom", + "opensearch": "http://a9.com/-/spec/opensearch/1.1/", + "arxiv": "http://arxiv.org/schemas/atom", +} + + +def parse_arxiv_feed(payload: str) -> ArxivFeed: + root = _parse_xml_root(payload) + opensearch = ArxivOpenSearchMeta( + total_results=_opensearch_int(root, "opensearch:totalResults"), + start_index=_opensearch_int(root, "opensearch:startIndex"), + items_per_page=_opensearch_int(root, "opensearch:itemsPerPage"), + ) + entries = [_parse_entry(entry_elem) for entry_elem in root.findall("atom:entry", _NAMESPACES)] + return ArxivFeed(entries=entries, opensearch=opensearch) + + +def _parse_xml_root(payload: str) -> ET.Element: + try: + return ET.fromstring(payload) + except ET.ParseError as exc: + raise ArxivParseError(f"Invalid arXiv XML payload: {exc}") from exc + + +def _opensearch_int(root: ET.Element, path: str) -> int: + text = _optional_text(root, path) + if text is None: + return 0 + try: + return int(text.strip()) + except ValueError as exc: + raise ArxivParseError(f"Invalid integer value at {path}: {text!r}") from exc + + +def _parse_entry(entry_elem: ET.Element) -> ArxivEntry: + entry_id_url = _required_text(entry_elem, "atom:id").strip() + arxiv_id = normalize_arxiv_id(entry_id_url) + title = _required_text(entry_elem, "atom:title").strip() + summary = (_optional_text(entry_elem, "atom:summary") or "").strip() + published = _optional_text(entry_elem, "atom:published") + updated = _optional_text(entry_elem, "atom:updated") + return ArxivEntry( + entry_id_url=entry_id_url, + arxiv_id=arxiv_id, + title=title, + summary=summary, + published=published, + updated=updated, + authors=_authors(entry_elem), + links=_links(entry_elem), + categories=_categories(entry_elem), + primary_category=_primary_category(entry_elem), + ) + + +def _required_text(elem: ET.Element, path: str) -> str: + text = _optional_text(elem, path) + if text is None or not text.strip(): + raise ArxivParseError(f"Missing required field: {path}") + return text + + +def _optional_text(elem: ET.Element, path: str) -> str | None: + node = elem.find(path, _NAMESPACES) + if node is None or node.text is None: + return None + return str(node.text) + + +def _authors(entry_elem: ET.Element) -> list[str]: + authors: list[str] = [] + for author in entry_elem.findall("atom:author", _NAMESPACES): + name = _optional_text(author, "atom:name") + if name: + authors.append(name.strip()) + return authors + + +def _links(entry_elem: ET.Element) -> list[str]: + values: list[str] = [] + for link in entry_elem.findall("atom:link", _NAMESPACES): + href = str(link.attrib.get("href") or "").strip() + if href: + values.append(href) + return values + + +def _categories(entry_elem: ET.Element) -> list[str]: + values: list[str] = [] + for cat in entry_elem.findall("atom:category", _NAMESPACES): + term = str(cat.attrib.get("term") or "").strip() + if term: + values.append(term) + return values + + +def _primary_category(entry_elem: ET.Element) -> str | None: + node = entry_elem.find("arxiv:primary_category", _NAMESPACES) + if node is None: + return None + value = str(node.attrib.get("term") or "").strip() + return value or None diff --git a/app/services/arxiv/rate_limit.py b/app/services/arxiv/rate_limit.py new file mode 100644 index 0000000..8d1a5a1 --- /dev/null +++ b/app/services/arxiv/rate_limit.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +import httpx +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ArxivRuntimeState +from app.db.session import get_session_factory +from app.logging_utils import structured_log +from app.services.arxiv.constants import ( + ARXIV_RATE_LIMIT_LOCK_KEY, + ARXIV_RATE_LIMIT_LOCK_NAMESPACE, + ARXIV_RUNTIME_STATE_KEY, + ARXIV_SOURCE_PATH_UNKNOWN, +) +from app.services.arxiv.errors import ArxivRateLimitError +from app.settings import settings + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ArxivCooldownStatus: + is_active: bool + remaining_seconds: float + cooldown_until: datetime | None + + +async def run_with_global_arxiv_limit( + *, + fetch: Callable[[], Awaitable[httpx.Response]], + source_path: str = ARXIV_SOURCE_PATH_UNKNOWN, +) -> httpx.Response: + response, hit_rate_limit = await _run_serialized_fetch( + fetch=fetch, + source_path=source_path, + ) + if hit_rate_limit: + raise ArxivRateLimitError("arXiv rate limit hit (429) — stopping batch") + return response + + +async def get_arxiv_cooldown_status(*, now_utc: datetime | None = None) -> ArxivCooldownStatus: + timestamp = _normalize_datetime(now_utc) or datetime.now(UTC) + session_factory = get_session_factory() + async with session_factory() as db_session: + result = await db_session.execute( + select(ArxivRuntimeState.cooldown_until).where(ArxivRuntimeState.state_key == ARXIV_RUNTIME_STATE_KEY) + ) + cooldown_until = _normalize_datetime(result.scalar_one_or_none()) + remaining_seconds = _cooldown_remaining_seconds(cooldown_until, now_utc=timestamp) + return ArxivCooldownStatus( + is_active=remaining_seconds > 0.0, + remaining_seconds=float(remaining_seconds), + cooldown_until=cooldown_until, + ) + + +async def _run_serialized_fetch( + *, + fetch: Callable[[], Awaitable[httpx.Response]], + source_path: str, +) -> tuple[httpx.Response, bool]: + session_factory = get_session_factory() + async with session_factory() as lock_session: + await _acquire_arxiv_lock(lock_session) + try: + async with session_factory() as db_session, db_session.begin(): + runtime_state = await _load_runtime_state_for_update(db_session) + wait_seconds = await _wait_for_allowed_slot_or_raise( + runtime_state, + source_path=source_path, + ) + runtime_state.next_allowed_at = datetime.now(UTC) + timedelta(seconds=_min_interval_seconds()) + + if wait_seconds > 0: + await asyncio.sleep(wait_seconds) + response = await fetch() + + async with session_factory() as db_session, db_session.begin(): + runtime_state = await _load_runtime_state_for_update(db_session) + hit_rate_limit = _record_post_response_state( + runtime_state, + response_status=int(response.status_code), + source_path=source_path, + ) + cooldown_until_value = runtime_state.cooldown_until + finally: + await _release_arxiv_lock(lock_session) + structured_log( + logger, + "info", + "arxiv.request_completed", + status_code=int(response.status_code), + wait_seconds=wait_seconds, + cooldown_remaining_seconds=_cooldown_remaining_seconds( + cooldown_until_value, now_utc=datetime.now(UTC) + ), + source_path=source_path, + ) + return response, hit_rate_limit + + +async def _acquire_arxiv_lock(db_session: AsyncSession) -> None: + await db_session.execute( + text("SELECT pg_advisory_lock(:namespace, :lock_key)"), + { + "namespace": ARXIV_RATE_LIMIT_LOCK_NAMESPACE, + "lock_key": ARXIV_RATE_LIMIT_LOCK_KEY, + }, + ) + + +async def _release_arxiv_lock(db_session: AsyncSession) -> None: + await db_session.execute( + text("SELECT pg_advisory_unlock(:namespace, :lock_key)"), + { + "namespace": ARXIV_RATE_LIMIT_LOCK_NAMESPACE, + "lock_key": ARXIV_RATE_LIMIT_LOCK_KEY, + }, + ) + + +async def _load_runtime_state_for_update(db_session: AsyncSession) -> ArxivRuntimeState: + result = await db_session.execute( + select(ArxivRuntimeState).where(ArxivRuntimeState.state_key == ARXIV_RUNTIME_STATE_KEY).with_for_update() + ) + state = result.scalar_one_or_none() + if state is not None: + return state + state = ArxivRuntimeState(state_key=ARXIV_RUNTIME_STATE_KEY) + db_session.add(state) + await db_session.flush() + return state + + +async def _wait_for_allowed_slot_or_raise( + runtime_state: ArxivRuntimeState, + *, + source_path: str, +) -> float: + now_utc = datetime.now(UTC) + cooldown_seconds = _cooldown_remaining_seconds(runtime_state.cooldown_until, now_utc=now_utc) + if cooldown_seconds > 0: + structured_log( + logger, + "info", + "arxiv.request_scheduled", + wait_seconds=0.0, + source_path=source_path, + cooldown_remaining_seconds=cooldown_seconds, + ) + raise ArxivRateLimitError(f"arXiv global cooldown active ({cooldown_seconds:.0f}s remaining)") + wait_seconds = _next_allowed_wait_seconds(runtime_state.next_allowed_at, now_utc=now_utc) + structured_log( + logger, + "info", + "arxiv.request_scheduled", + wait_seconds=wait_seconds, + source_path=source_path, + cooldown_remaining_seconds=0.0, + ) + return wait_seconds + + +def _record_post_response_state( + runtime_state: ArxivRuntimeState, + *, + response_status: int, + source_path: str, +) -> bool: + now_utc = datetime.now(UTC) + runtime_state.next_allowed_at = now_utc + timedelta(seconds=_min_interval_seconds()) + if response_status == 429: + cooldown_seconds = _cooldown_seconds() + runtime_state.cooldown_until = now_utc + timedelta(seconds=cooldown_seconds) + structured_log( + logger, + "warning", + "arxiv.cooldown_activated", + cooldown_remaining_seconds=cooldown_seconds, + source_path=source_path, + ) + return True + if _cooldown_remaining_seconds(runtime_state.cooldown_until, now_utc=now_utc) <= 0: + runtime_state.cooldown_until = None + return False + + +def _cooldown_remaining_seconds(cooldown_until: datetime | None, *, now_utc: datetime) -> float: + bounded = _normalize_datetime(cooldown_until) + if bounded is None: + return 0.0 + return max((bounded - now_utc).total_seconds(), 0.0) + + +def _next_allowed_wait_seconds(next_allowed_at: datetime | None, *, now_utc: datetime) -> float: + bounded = _normalize_datetime(next_allowed_at) + if bounded is None: + return 0.0 + return max((bounded - now_utc).total_seconds(), 0.0) + + +def _normalize_datetime(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value + + +def _min_interval_seconds() -> float: + return max(float(settings.arxiv_min_interval_seconds), 0.0) + + +def _cooldown_seconds() -> float: + return max(float(settings.arxiv_rate_limit_cooldown_seconds), 0.0) diff --git a/app/services/arxiv/types.py b/app/services/arxiv/types.py new file mode 100644 index 0000000..574ed93 --- /dev/null +++ b/app/services/arxiv/types.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +ArxivSortBy = Literal["relevance", "lastUpdatedDate", "submittedDate"] +ArxivSortOrder = Literal["ascending", "descending"] + + +@dataclass(frozen=True) +class ArxivOpenSearchMeta: + total_results: int = 0 + start_index: int = 0 + items_per_page: int = 0 + + +@dataclass(frozen=True) +class ArxivEntry: + entry_id_url: str + arxiv_id: str | None + title: str + summary: str + published: str | None + updated: str | None + authors: list[str] = field(default_factory=list) + links: list[str] = field(default_factory=list) + categories: list[str] = field(default_factory=list) + primary_category: str | None = None + + +@dataclass(frozen=True) +class ArxivFeed: + entries: list[ArxivEntry] = field(default_factory=list) + opensearch: ArxivOpenSearchMeta = field(default_factory=ArxivOpenSearchMeta) diff --git a/app/services/crossref/__init__.py b/app/services/crossref/__init__.py new file mode 100644 index 0000000..98882f0 --- /dev/null +++ b/app/services/crossref/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from app.services.crossref.application import discover_doi_for_publication + +__all__ = ["discover_doi_for_publication"] diff --git a/app/services/domains/crossref/application.py b/app/services/crossref/application.py similarity index 61% rename from app/services/domains/crossref/application.py rename to app/services/crossref/application.py index 569c12d..a546a9f 100644 --- a/app/services/domains/crossref/application.py +++ b/app/services/crossref/application.py @@ -5,22 +5,28 @@ import logging import re import threading import time +from importlib.metadata import version as pkg_version from typing import TYPE_CHECKING from crossref.restful import Etiquette, Works -from app.services.domains.doi.normalize import normalize_doi +from app.logging_utils import structured_log +from app.services.doi.normalize import normalize_doi from app.settings import settings +_APP_VERSION = pkg_version("scholarr") + if TYPE_CHECKING: - from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem + from app.services.publications.types import PublicationListItem, UnreadPublicationItem TOKEN_RE = re.compile(r"[a-z0-9]+") -NON_ALNUM_RE = re.compile(r"[^a-z0-9\\s]+") +NON_ALNUM_RE = re.compile(r"[^a-z0-9\s]+") STOP_WORDS = {"the", "and", "for", "with", "from", "method", "study", "analysis"} _RATE_LOCK = threading.Lock() _LAST_REQUEST_AT = 0.0 logger = logging.getLogger(__name__) +STRICT_TITLE_MATCH_THRESHOLD = 0.75 +RELAXED_TITLE_MATCH_THRESHOLD = 0.85 def _rate_limit_wait(min_interval_seconds: float) -> None: @@ -29,13 +35,14 @@ def _rate_limit_wait(min_interval_seconds: float) -> None: with _RATE_LOCK: elapsed = time.monotonic() - _LAST_REQUEST_AT remaining = interval - elapsed - if remaining > 0: - time.sleep(remaining) + if remaining > 0: + time.sleep(remaining) + with _RATE_LOCK: _LAST_REQUEST_AT = time.monotonic() def _normalized_tokens(value: str) -> list[str]: - lowered = value.lower().replace("’", "'").replace("“", "\"").replace("”", "\"") + lowered = value.lower().replace("’", "'").replace("“", '"').replace("”", '"') lowered = NON_ALNUM_RE.sub(" ", lowered) return [token for token in TOKEN_RE.findall(lowered) if len(token) >= 3] @@ -132,7 +139,42 @@ def _candidate_rank(*, title: str, year: int | None, item: dict) -> tuple[float, return score, doi -def _best_candidate_doi( +def _year_delta(source_year: int | None, candidate_year: int | None) -> int | None: + if source_year is None or candidate_year is None: + return None + return abs(int(source_year) - int(candidate_year)) + + +def _candidate_rank_relaxed( + *, + title: str, + year: int | None, + item: dict, + author_surname: str | None, +) -> tuple[float, str | None]: + doi = normalize_doi(str(item.get("DOI") or "")) + if doi is None: + return 0.0, None + score = _title_match_score(title, _candidate_title(item)) + if score <= 0: + return 0.0, None + candidate_year = _candidate_year(item) + delta = _year_delta(year, candidate_year) + if delta is not None: + if delta <= 1: + score += 0.05 + elif delta <= 3: + score += 0.0 + elif delta <= 5: + score -= 0.03 + else: + score -= 0.08 + if _candidate_author_match(item, author_surname): + score += 0.03 + return score, doi + + +def _best_candidate_doi_strict( *, title: str, year: int | None, @@ -149,7 +191,7 @@ def _best_candidate_doi( continue score, doi = _candidate_rank(title=title, year=year, item=item) candidate_year = _candidate_year(item) - if doi is None or score < 0.75: + if doi is None or score < STRICT_TITLE_MATCH_THRESHOLD: continue if score > best_score: best_score = score @@ -166,9 +208,95 @@ def _best_candidate_doi( return best_doi +def _best_candidate_doi_relaxed( + *, + title: str, + year: int | None, + items: list[dict], + author_surname: str | None, +) -> str | None: + best_score = 0.0 + best_doi: str | None = None + best_author_match = False + best_delta: int | None = None + best_year: int | None = None + for item in items: + if not isinstance(item, dict): + continue + score, doi = _candidate_rank_relaxed( + title=title, + year=year, + item=item, + author_surname=author_surname, + ) + if doi is None or score < RELAXED_TITLE_MATCH_THRESHOLD: + continue + candidate_year = _candidate_year(item) + candidate_author_match = _candidate_author_match(item, author_surname) + candidate_delta = _year_delta(year, candidate_year) + if score > best_score: + best_score = score + best_doi = doi + best_author_match = candidate_author_match + best_delta = candidate_delta + best_year = candidate_year + continue + if abs(score - best_score) > 0.02: + continue + if candidate_author_match and not best_author_match: + best_doi = doi + best_author_match = True + best_delta = candidate_delta + best_year = candidate_year + continue + if best_delta is None and candidate_delta is not None: + best_doi = doi + best_author_match = candidate_author_match + best_delta = candidate_delta + best_year = candidate_year + continue + if best_delta is not None and candidate_delta is not None and candidate_delta < best_delta: + best_doi = doi + best_author_match = candidate_author_match + best_delta = candidate_delta + best_year = candidate_year + continue + if best_year is None or candidate_year is None: + continue + if candidate_year < best_year: + best_doi = doi + best_author_match = candidate_author_match + best_delta = candidate_delta + best_year = candidate_year + return best_doi + + +def _best_candidate_doi( + *, + title: str, + year: int | None, + items: list[dict], + author_surname: str | None, +) -> str | None: + strict_match = _best_candidate_doi_strict( + title=title, + year=year, + items=items, + author_surname=author_surname, + ) + if strict_match: + return strict_match + return _best_candidate_doi_relaxed( + title=title, + year=year, + items=items, + author_surname=author_surname, + ) + + def _works_client(email: str | None) -> Works: if email: - etiquette = Etiquette(settings.app_name, "0.1.0", "https://scholarr.local", email) + etiquette = Etiquette(settings.app_name, _APP_VERSION, "https://scholarr.local", email) return Works(etiquette=etiquette) return Works() @@ -223,7 +351,8 @@ async def _fetch_items( ), timeout=timeout, ) - except Exception: + except Exception as exc: + structured_log(logger, "warning", "crossref.fetch_failed", error=str(exc)) return [] @@ -254,6 +383,6 @@ async def discover_doi_for_publication( author_surname=author_surname, ) if doi: - logger.debug("crossref.doi_discovered", extra={"event": "crossref.doi_discovered"}) + structured_log(logger, "debug", "crossref.doi_discovered") return doi return None diff --git a/app/services/dbops/__init__.py b/app/services/dbops/__init__.py new file mode 100644 index 0000000..3209dec --- /dev/null +++ b/app/services/dbops/__init__.py @@ -0,0 +1,13 @@ +from app.services.dbops.application import run_publication_link_repair +from app.services.dbops.integrity import collect_integrity_report +from app.services.dbops.near_duplicate_repair import ( + run_publication_near_duplicate_repair, +) +from app.services.dbops.query import list_repair_jobs + +__all__ = [ + "collect_integrity_report", + "list_repair_jobs", + "run_publication_link_repair", + "run_publication_near_duplicate_repair", +] diff --git a/app/services/domains/dbops/application.py b/app/services/dbops/application.py similarity index 91% rename from app/services/domains/dbops/application.py rename to app/services/dbops/application.py index e7ace77..a5fa077 100644 --- a/app/services/domains/dbops/application.py +++ b/app/services/dbops/application.py @@ -1,14 +1,13 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any -from sqlalchemy import delete, exists, func, select, update +from sqlalchemy import CursorResult, delete, exists, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import DataRepairJob, IngestionQueueItem, Publication, ScholarProfile, ScholarPublication - REPAIR_STATUS_PLANNED = "planned" REPAIR_STATUS_RUNNING = "running" REPAIR_STATUS_COMPLETED = "completed" @@ -18,7 +17,7 @@ SCOPE_MODE_ALL_USERS = "all_users" def _utcnow() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) def _normalize_scope_mode(scope_mode: str) -> str: @@ -105,11 +104,7 @@ async def _count_orphan_publications(db_session: AsyncSession) -> int: stmt = ( select(func.count()) .select_from(Publication) - .where( - ~exists( - select(1).where(ScholarPublication.publication_id == Publication.id) - ) - ) + .where(~exists(select(1).where(ScholarPublication.publication_id == Publication.id))) ) result = await db_session.execute(stmt) return int(result.scalar_one() or 0) @@ -157,10 +152,8 @@ def _job_summary( async def _delete_links_for_targets(db_session: AsyncSession, *, target_scholar_profile_ids: list[int]) -> int: - result = await db_session.execute( - delete(ScholarPublication).where( - ScholarPublication.scholar_profile_id.in_(target_scholar_profile_ids) - ) + result: CursorResult[Any] = await db_session.execute( # type: ignore[assignment] + delete(ScholarPublication).where(ScholarPublication.scholar_profile_id.in_(target_scholar_profile_ids)) ) return int(result.rowcount or 0) @@ -171,12 +164,10 @@ async def _delete_queue_for_targets( user_id: int | None, target_scholar_profile_ids: list[int], ) -> int: - stmt = delete(IngestionQueueItem).where( - IngestionQueueItem.scholar_profile_id.in_(target_scholar_profile_ids) - ) + stmt = delete(IngestionQueueItem).where(IngestionQueueItem.scholar_profile_id.in_(target_scholar_profile_ids)) if user_id is not None: stmt = stmt.where(IngestionQueueItem.user_id == user_id) - result = await db_session.execute(stmt) + result: CursorResult[Any] = await db_session.execute(stmt) # type: ignore[assignment] return int(result.rowcount or 0) @@ -189,7 +180,7 @@ async def _reset_scholar_tracking_state( stmt = update(ScholarProfile).where(ScholarProfile.id.in_(target_scholar_profile_ids)) if user_id is not None: stmt = stmt.where(ScholarProfile.user_id == user_id) - result = await db_session.execute( + result: CursorResult[Any] = await db_session.execute( # type: ignore[assignment] stmt.values( baseline_completed=False, last_initial_page_fingerprint_sha256=None, @@ -202,10 +193,8 @@ async def _reset_scholar_tracking_state( async def _delete_orphan_publications(db_session: AsyncSession) -> int: - result = await db_session.execute( - delete(Publication).where( - ~exists(select(1).where(ScholarPublication.publication_id == Publication.id)) - ) + result: CursorResult[Any] = await db_session.execute( # type: ignore[assignment] + delete(Publication).where(~exists(select(1).where(ScholarPublication.publication_id == Publication.id))) ) return int(result.rowcount or 0) diff --git a/app/services/domains/dbops/integrity.py b/app/services/dbops/integrity.py similarity index 98% rename from app/services/domains/dbops/integrity.py rename to app/services/dbops/integrity.py index 4d9c43c..f05d9a3 100644 --- a/app/services/domains/dbops/integrity.py +++ b/app/services/dbops/integrity.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from sqlalchemy import func, select, text @@ -168,7 +168,7 @@ async def collect_integrity_report(db_session: AsyncSession) -> dict[str, Any]: return { "status": _status_from_issues(failures=failures, warnings=warnings), - "checked_at": datetime.now(timezone.utc).isoformat(), + "checked_at": datetime.now(UTC).isoformat(), "failures": failures, "warnings": warnings, "checks": checks, diff --git a/app/services/dbops/near_duplicate_repair.py b/app/services/dbops/near_duplicate_repair.py new file mode 100644 index 0000000..70c902c --- /dev/null +++ b/app/services/dbops/near_duplicate_repair.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import DataRepairJob +from app.services.dbops.application import ( + REPAIR_STATUS_COMPLETED, + REPAIR_STATUS_FAILED, + REPAIR_STATUS_PLANNED, + REPAIR_STATUS_RUNNING, +) +from app.services.publications import dedup as dedup_service + +NEAR_DUP_JOB_NAME = "repair_publication_near_duplicates" +NEAR_DUP_DEFAULT_MAX_CLUSTERS = 25 + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +def _normalized_cluster_keys(values: list[str] | None) -> list[str]: + if not values: + return [] + seen: set[str] = set() + normalized: list[str] = [] + for value in values: + key = str(value or "").strip().lower() + if not key or key in seen: + continue + seen.add(key) + normalized.append(key) + return normalized + + +def _normalized_max_clusters(value: int) -> int: + return max(1, min(int(value), 200)) + + +def _scope_payload( + *, + similarity_threshold: float, + min_shared_tokens: int, + max_year_delta: int, + max_clusters: int, + selected_cluster_keys: list[str], +) -> dict[str, Any]: + return { + "similarity_threshold": float(similarity_threshold), + "min_shared_tokens": int(min_shared_tokens), + "max_year_delta": int(max_year_delta), + "max_clusters": int(max_clusters), + "selected_cluster_keys": selected_cluster_keys, + } + + +async def _create_job( + db_session: AsyncSession, + *, + requested_by: str | None, + scope: dict[str, Any], + dry_run: bool, +) -> DataRepairJob: + job = DataRepairJob( + job_name=NEAR_DUP_JOB_NAME, + requested_by=(requested_by or "").strip() or None, + scope=scope, + dry_run=bool(dry_run), + status=REPAIR_STATUS_PLANNED, + summary={}, + ) + db_session.add(job) + await db_session.flush() + job.status = REPAIR_STATUS_RUNNING + job.started_at = _utcnow() + return job + + +def _selected_clusters( + *, + clusters: list[dedup_service.NearDuplicateCluster], + selected_cluster_keys: list[str], +) -> tuple[list[dedup_service.NearDuplicateCluster], list[str]]: + if not selected_cluster_keys: + return [], [] + by_key = {cluster.cluster_key.lower(): cluster for cluster in clusters} + selected: list[dedup_service.NearDuplicateCluster] = [] + missing: list[str] = [] + for key in selected_cluster_keys: + cluster = by_key.get(key) + if cluster is None: + missing.append(key) + continue + selected.append(cluster) + return selected, missing + + +async def _merge_selected_clusters( + db_session: AsyncSession, + *, + selected_clusters: list[dedup_service.NearDuplicateCluster], +) -> int: + merged_publications = 0 + for cluster in selected_clusters: + merged_publications += await dedup_service.merge_near_duplicate_cluster( + db_session, + cluster=cluster, + ) + return merged_publications + + +def _clusters_payload( + *, + clusters: list[dedup_service.NearDuplicateCluster], + max_clusters: int, +) -> list[dict[str, object]]: + preview = clusters[:max_clusters] + return [dedup_service.near_duplicate_cluster_payload(cluster) for cluster in preview] + + +def _summary_payload( + *, + dry_run: bool, + cluster_count: int, + selected_count: int, + missing_count: int, + merged_publications: int, + max_clusters: int, +) -> dict[str, Any]: + return { + "dry_run": bool(dry_run), + "candidate_cluster_count": int(cluster_count), + "selected_cluster_count": int(selected_count), + "missing_selected_cluster_count": int(missing_count), + "merged_publications": int(merged_publications), + "preview_cluster_count": int(min(cluster_count, max_clusters)), + } + + +async def _complete_job( + db_session: AsyncSession, + *, + job: DataRepairJob, + scope: dict[str, Any], + summary: dict[str, Any], + clusters: list[dict[str, object]], +) -> dict[str, Any]: + job.status = REPAIR_STATUS_COMPLETED + job.finished_at = _utcnow() + job.summary = summary + await db_session.commit() + return { + "job_id": int(job.id), + "status": job.status, + "scope": scope, + "summary": summary, + "clusters": clusters, + } + + +async def _fail_job(db_session: AsyncSession, *, job: DataRepairJob, error: Exception) -> None: + from sqlalchemy.orm import make_transient + + await db_session.rollback() + make_transient(job) + job.status = REPAIR_STATUS_FAILED + job.error_text = str(error) + job.finished_at = _utcnow() + db_session.add(job) + await db_session.commit() + + +async def run_publication_near_duplicate_repair( + db_session: AsyncSession, + *, + dry_run: bool = True, + similarity_threshold: float = dedup_service.NEAR_DUP_DEFAULT_SIMILARITY_THRESHOLD, + min_shared_tokens: int = dedup_service.NEAR_DUP_DEFAULT_MIN_SHARED_TOKENS, + max_year_delta: int = dedup_service.NEAR_DUP_DEFAULT_MAX_YEAR_DELTA, + max_clusters: int = NEAR_DUP_DEFAULT_MAX_CLUSTERS, + selected_cluster_keys: list[str] | None = None, + requested_by: str | None = None, +) -> dict[str, Any]: + normalized_keys = _normalized_cluster_keys(selected_cluster_keys) + bounded_clusters = _normalized_max_clusters(max_clusters) + scope = _scope_payload( + similarity_threshold=similarity_threshold, + min_shared_tokens=min_shared_tokens, + max_year_delta=max_year_delta, + max_clusters=bounded_clusters, + selected_cluster_keys=normalized_keys, + ) + job = await _create_job(db_session, requested_by=requested_by, scope=scope, dry_run=dry_run) + try: + clusters = await dedup_service.find_near_duplicate_clusters( + db_session, + similarity_threshold=similarity_threshold, + min_shared_tokens=min_shared_tokens, + max_year_delta=max_year_delta, + ) + selected, missing = _selected_clusters(clusters=clusters, selected_cluster_keys=normalized_keys) + merged_publications = 0 + if not dry_run: + if not selected: + raise ValueError("No selected near-duplicate clusters matched current data.") + merged_publications = await _merge_selected_clusters(db_session, selected_clusters=selected) + preview = _clusters_payload(clusters=clusters, max_clusters=bounded_clusters) + summary = _summary_payload( + dry_run=dry_run, + cluster_count=len(clusters), + selected_count=len(selected), + missing_count=len(missing), + merged_publications=merged_publications, + max_clusters=bounded_clusters, + ) + return await _complete_job( + db_session, + job=job, + scope=scope, + summary=summary, + clusters=preview, + ) + except Exception as exc: + await _fail_job(db_session, job=job, error=exc) + raise diff --git a/app/services/domains/dbops/query.py b/app/services/dbops/query.py similarity index 76% rename from app/services/domains/dbops/query.py rename to app/services/dbops/query.py index 2e95e31..f65c582 100644 --- a/app/services/domains/dbops/query.py +++ b/app/services/dbops/query.py @@ -16,7 +16,5 @@ async def list_repair_jobs( limit: int = 50, ) -> list[DataRepairJob]: bounded = _bounded_limit(limit) - result = await db_session.execute( - select(DataRepairJob).order_by(DataRepairJob.created_at.desc()).limit(bounded) - ) + result = await db_session.execute(select(DataRepairJob).order_by(DataRepairJob.created_at.desc()).limit(bounded)) return list(result.scalars()) diff --git a/app/services/domains/doi/normalize.py b/app/services/doi/normalize.py similarity index 100% rename from app/services/domains/doi/normalize.py rename to app/services/doi/normalize.py diff --git a/app/services/domains/crossref/__init__.py b/app/services/domains/crossref/__init__.py deleted file mode 100644 index 8adb7ba..0000000 --- a/app/services/domains/crossref/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from app.services.domains.crossref.application import discover_doi_for_publication - -__all__ = ["discover_doi_for_publication"] diff --git a/app/services/domains/dbops/__init__.py b/app/services/domains/dbops/__init__.py deleted file mode 100644 index f5b02e4..0000000 --- a/app/services/domains/dbops/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from app.services.domains.dbops.application import run_publication_link_repair -from app.services.domains.dbops.integrity import collect_integrity_report -from app.services.domains.dbops.query import list_repair_jobs - -__all__ = ["collect_integrity_report", "list_repair_jobs", "run_publication_link_repair"] diff --git a/app/services/domains/ingestion/application.py b/app/services/domains/ingestion/application.py deleted file mode 100644 index 54a7e25..0000000 --- a/app/services/domains/ingestion/application.py +++ /dev/null @@ -1,2182 +0,0 @@ -from __future__ import annotations - -import asyncio -from datetime import datetime, timezone -import hashlib -import logging -from typing import Any - -from sqlalchemy import select, text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ( - CrawlRun, - Publication, - RunStatus, - RunTriggerType, - ScholarProfile, - ScholarPublication, -) -from app.services.domains.ingestion.constants import ( - FAILED_STATES, - FAILURE_BUCKET_BLOCKED, - FAILURE_BUCKET_INGESTION, - FAILURE_BUCKET_LAYOUT, - FAILURE_BUCKET_NETWORK, - FAILURE_BUCKET_OTHER, - RESUMABLE_PARTIAL_REASON_PREFIXES, - RESUMABLE_PARTIAL_REASONS, - RUN_LOCK_NAMESPACE, -) -from app.services.domains.doi.normalize import first_doi_from_texts -from app.services.domains.ingestion.fingerprints import ( - _build_body_excerpt, - _dedupe_publication_candidates, - _next_cstart_value, - build_initial_page_fingerprint, - build_publication_fingerprint, - build_publication_url, - normalize_title, -) -from app.services.domains.ingestion import queue as queue_service -from app.services.domains.ingestion import safety as run_safety_service -from app.services.domains.ingestion.types import ( - PagedLoopState, - PagedParseResult, - RunAlertSummary, - RunAlreadyInProgressError, - RunBlockedBySafetyPolicyError, - RunExecutionSummary, - RunFailureSummary, - RunProgress, - ScholarProcessingOutcome, -) -from app.services.domains.settings import application as user_settings_service -from app.services.domains.scholar.parser import ( - ParseState, - ParsedProfilePage, - PublicationCandidate, - ScholarParserError, - parse_profile_page, -) -from app.services.domains.scholar.source import FetchResult, ScholarSource -from app.settings import settings - -logger = logging.getLogger(__name__) - - -def _int_or_default(value: Any, default: int = 0) -> int: - try: - return int(value) - except (TypeError, ValueError): - return default - - -def _classify_failure_bucket(*, state: str, state_reason: str) -> str: - reason = state_reason.strip().lower() - normalized_state = state.strip().lower() - - if normalized_state == ParseState.BLOCKED_OR_CAPTCHA.value or reason.startswith("blocked_"): - return FAILURE_BUCKET_BLOCKED - if normalized_state == ParseState.NETWORK_ERROR.value or reason.startswith("network_"): - return FAILURE_BUCKET_NETWORK - if normalized_state == ParseState.LAYOUT_CHANGED.value: - return FAILURE_BUCKET_LAYOUT - if normalized_state == "ingestion_error": - return FAILURE_BUCKET_INGESTION - return FAILURE_BUCKET_OTHER - - -class ScholarIngestionService: - def __init__(self, *, source: ScholarSource) -> None: - self._source = source - - @staticmethod - def _effective_request_delay_seconds(value: int) -> int: - policy_minimum = user_settings_service.resolve_request_delay_minimum( - settings.ingestion_min_request_delay_seconds - ) - return max(policy_minimum, _int_or_default(value, policy_minimum)) - - @staticmethod - def _log_request_delay_coercion( - *, - user_id: int, - requested_request_delay_seconds: int, - effective_request_delay_seconds: int, - ) -> None: - logger.warning( - "ingestion.request_delay_coerced_to_policy_floor", - extra={ - "event": "ingestion.request_delay_coerced_to_policy_floor", - "user_id": user_id, - "requested_request_delay_seconds": requested_request_delay_seconds, - "effective_request_delay_seconds": effective_request_delay_seconds, - "policy_minimum_request_delay_seconds": user_settings_service.resolve_request_delay_minimum( - settings.ingestion_min_request_delay_seconds - ), - "metric_name": "ingestion_request_delay_coerced_total", - "metric_value": 1, - }, - ) - - async def _load_user_settings_for_run( - self, - db_session: AsyncSession, - *, - user_id: int, - trigger_type: RunTriggerType, - ): - user_settings = await user_settings_service.get_or_create_settings( - db_session, - user_id=user_id, - ) - await self._enforce_safety_gate( - db_session, - user_settings=user_settings, - user_id=user_id, - trigger_type=trigger_type, - ) - return user_settings - - async def _enforce_safety_gate( - self, - db_session: AsyncSession, - *, - user_settings, - user_id: int, - trigger_type: RunTriggerType, - ) -> None: - now_utc = datetime.now(timezone.utc) - previous = run_safety_service.get_safety_event_context(user_settings, now_utc=now_utc) - if run_safety_service.clear_expired_cooldown(user_settings, now_utc=now_utc): - await db_session.commit() - await db_session.refresh(user_settings) - logger.info( - "ingestion.safety_cooldown_cleared", - extra={ - "event": "ingestion.safety_cooldown_cleared", - "user_id": user_id, - "reason": previous.get("cooldown_reason"), - "cooldown_until": previous.get("cooldown_until"), - "metric_name": "ingestion_safety_cooldown_cleared_total", - "metric_value": 1, - }, - ) - now_utc = datetime.now(timezone.utc) - if run_safety_service.is_cooldown_active(user_settings, now_utc=now_utc): - await self._raise_safety_blocked_start( - db_session, - user_settings=user_settings, - user_id=user_id, - trigger_type=trigger_type, - now_utc=now_utc, - ) - - async def _raise_safety_blocked_start( - self, - db_session: AsyncSession, - *, - user_settings, - user_id: int, - trigger_type: RunTriggerType, - now_utc: datetime, - ) -> None: - safety_state = run_safety_service.register_cooldown_blocked_start( - user_settings, - now_utc=now_utc, - ) - await db_session.commit() - logger.warning( - "ingestion.safety_policy_blocked_run_start", - extra={ - "event": "ingestion.safety_policy_blocked_run_start", - "user_id": user_id, - "trigger_type": trigger_type.value, - "reason": safety_state.get("cooldown_reason"), - "cooldown_until": safety_state.get("cooldown_until"), - "cooldown_remaining_seconds": safety_state.get("cooldown_remaining_seconds"), - "blocked_start_count": ((safety_state.get("counters") or {}).get("blocked_start_count")), - "metric_name": "ingestion_safety_run_start_blocked_total", - "metric_value": 1, - }, - ) - raise RunBlockedBySafetyPolicyError( - code="scrape_cooldown_active", - message="Scrape safety cooldown is active; run start is temporarily blocked.", - safety_state=safety_state, - ) - - @staticmethod - def _normalize_run_targets( - *, - scholar_profile_ids: set[int] | None, - start_cstart_by_scholar_id: dict[int, int] | None, - ) -> tuple[set[int] | None, dict[int, int]]: - filtered_scholar_ids = ( - {int(value) for value in scholar_profile_ids} - if scholar_profile_ids is not None - else None - ) - start_cstart_map = { - int(key): max(0, int(value)) - for key, value in (start_cstart_by_scholar_id or {}).items() - } - return filtered_scholar_ids, start_cstart_map - - async def _load_target_scholars( - self, - db_session: AsyncSession, - *, - user_id: int, - filtered_scholar_ids: set[int] | None, - ) -> list[ScholarProfile]: - scholars_stmt = ( - select(ScholarProfile) - .where(ScholarProfile.user_id == user_id, ScholarProfile.is_enabled.is_(True)) - .order_by(ScholarProfile.created_at.asc(), ScholarProfile.id.asc()) - ) - if filtered_scholar_ids is not None: - scholars_stmt = scholars_stmt.where(ScholarProfile.id.in_(filtered_scholar_ids)) - scholars_result = await db_session.execute(scholars_stmt) - scholars = list(scholars_result.scalars().all()) - await self._clear_missing_filtered_jobs( - db_session, - user_id=user_id, - filtered_scholar_ids=filtered_scholar_ids, - scholars=scholars, - ) - return scholars - - async def _clear_missing_filtered_jobs( - self, - db_session: AsyncSession, - *, - user_id: int, - filtered_scholar_ids: set[int] | None, - scholars: list[ScholarProfile], - ) -> None: - if filtered_scholar_ids is None: - return - found_ids = {int(scholar.id) for scholar in scholars} - missing_ids = filtered_scholar_ids - found_ids - for scholar_profile_id in missing_ids: - await queue_service.clear_job_for_scholar( - db_session, - user_id=user_id, - scholar_profile_id=scholar_profile_id, - ) - - @staticmethod - def _create_running_run( - *, - user_id: int, - trigger_type: RunTriggerType, - scholar_count: int, - idempotency_key: str | None, - ) -> CrawlRun: - return CrawlRun( - user_id=user_id, - trigger_type=trigger_type, - status=RunStatus.RUNNING, - scholar_count=scholar_count, - new_pub_count=0, - idempotency_key=idempotency_key, - error_log={}, - ) - - @staticmethod - def _log_run_started( - *, - user_id: int, - trigger_type: RunTriggerType, - scholar_count: int, - filtered: bool, - request_delay_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - max_pages_per_scholar: int, - page_size: int, - idempotency_key: str | None, - alert_blocked_failure_threshold: int, - alert_network_failure_threshold: int, - alert_retry_scheduled_threshold: int, - ) -> None: - logger.info( - "ingestion.run_started", - extra={ - "event": "ingestion.run_started", - "user_id": user_id, - "trigger_type": trigger_type.value, - "scholar_count": scholar_count, - "is_filtered_run": filtered, - "request_delay_seconds": request_delay_seconds, - "network_error_retries": network_error_retries, - "retry_backoff_seconds": retry_backoff_seconds, - "max_pages_per_scholar": max_pages_per_scholar, - "page_size": page_size, - "idempotency_key": idempotency_key, - "alert_blocked_failure_threshold": alert_blocked_failure_threshold, - "alert_network_failure_threshold": alert_network_failure_threshold, - "alert_retry_scheduled_threshold": alert_retry_scheduled_threshold, - }, - ) - - @staticmethod - async def _wait_between_scholars(*, index: int, request_delay_seconds: int) -> None: - if index <= 0 or request_delay_seconds <= 0: - return - await asyncio.sleep(float(request_delay_seconds)) - - @staticmethod - def _assert_valid_paged_parse_result( - *, - scholar_id: str, - paged_parse_result: PagedParseResult, - ) -> None: - parsed_page = paged_parse_result.parsed_page - if parsed_page.state in {ParseState.OK, ParseState.NO_RESULTS}: - if any(code.startswith("layout_") for code in parsed_page.warnings): - raise RuntimeError(f"Layout warning marked as terminal for scholar_id={scholar_id}.") - for publication in paged_parse_result.publications: - if not publication.title.strip(): - raise RuntimeError(f"Malformed publication title for scholar_id={scholar_id}.") - if publication.citation_count is not None and int(publication.citation_count) < 0: - raise RuntimeError(f"Negative citation count for scholar_id={scholar_id}.") - - @staticmethod - def _apply_first_page_profile_metadata( - *, - scholar: ScholarProfile, - paged_parse_result: PagedParseResult, - run_dt: datetime, - ) -> None: - first_page = paged_parse_result.first_page_parsed_page - if first_page.profile_name and not (scholar.display_name or "").strip(): - scholar.display_name = first_page.profile_name - if first_page.profile_image_url: - scholar.profile_image_url = first_page.profile_image_url - if paged_parse_result.first_page_fingerprint_sha256: - scholar.last_initial_page_fingerprint_sha256 = paged_parse_result.first_page_fingerprint_sha256 - scholar.last_initial_page_checked_at = run_dt - - @staticmethod - def _log_scholar_parsed( - *, - user_id: int, - run_id: int, - scholar: ScholarProfile, - paged_parse_result: PagedParseResult, - ) -> None: - parsed_page = paged_parse_result.parsed_page - logger.info( - "ingestion.scholar_parsed", - extra={ - "event": "ingestion.scholar_parsed", - "user_id": user_id, - "crawl_run_id": run_id, - "scholar_profile_id": scholar.id, - "scholar_id": scholar.scholar_id, - "state": parsed_page.state.value, - "publication_count": len(paged_parse_result.publications), - "has_show_more_button": parsed_page.has_show_more_button, - "pages_fetched": paged_parse_result.pages_fetched, - "pages_attempted": paged_parse_result.pages_attempted, - "has_more_remaining": paged_parse_result.has_more_remaining, - "pagination_truncated_reason": paged_parse_result.pagination_truncated_reason, - "warning_count": len(parsed_page.warnings), - "skipped_no_change": paged_parse_result.skipped_no_change, - }, - ) - - @staticmethod - def _build_result_entry( - *, - scholar: ScholarProfile, - start_cstart: int, - paged_parse_result: PagedParseResult, - ) -> dict[str, Any]: - parsed_page = paged_parse_result.parsed_page - return { - "scholar_profile_id": scholar.id, - "scholar_id": scholar.scholar_id, - "state": parsed_page.state.value, - "state_reason": parsed_page.state_reason, - "outcome": "failed", - "attempt_count": len(paged_parse_result.attempt_log), - "publication_count": len(paged_parse_result.publications), - "start_cstart": start_cstart, - "articles_range": parsed_page.articles_range, - "warnings": parsed_page.warnings, - "has_show_more_button": parsed_page.has_show_more_button, - "pages_fetched": paged_parse_result.pages_fetched, - "pages_attempted": paged_parse_result.pages_attempted, - "has_more_remaining": paged_parse_result.has_more_remaining, - "pagination_truncated_reason": paged_parse_result.pagination_truncated_reason, - "continuation_cstart": paged_parse_result.continuation_cstart, - "skipped_no_change": paged_parse_result.skipped_no_change, - "initial_page_fingerprint_sha256": paged_parse_result.first_page_fingerprint_sha256, - } - - def _skipped_no_change_outcome( - self, - *, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], - ) -> ScholarProcessingOutcome: - first_page = paged_parse_result.first_page_parsed_page - scholar.last_run_status = RunStatus.SUCCESS - scholar.last_run_dt = run_dt - result_entry["state"] = first_page.state.value - result_entry["state_reason"] = "no_change_initial_page_signature" - result_entry["outcome"] = "success" - result_entry["publication_count"] = 0 - result_entry["warnings"] = first_page.warnings - result_entry["debug"] = { - "state_reason": "no_change_initial_page_signature", - "first_page_fingerprint_sha256": paged_parse_result.first_page_fingerprint_sha256, - "attempt_log": paged_parse_result.attempt_log, - "page_logs": paged_parse_result.page_logs, - } - return ScholarProcessingOutcome( - result_entry=result_entry, - succeeded_count_delta=1, - failed_count_delta=0, - partial_count_delta=0, - discovered_publication_count=0, - ) - - async def _upsert_publications_outcome( - self, - db_session: AsyncSession, - *, - run: CrawlRun, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], - ) -> ScholarProcessingOutcome: - parsed_page = paged_parse_result.parsed_page - publications = paged_parse_result.publications - had_page_failure = parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS} - has_partial_set = len(publications) > 0 and had_page_failure - if (not had_page_failure) or has_partial_set: - return await self._upsert_success_or_exception( - db_session, - run=run, - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - has_partial_publication_set=has_partial_set, - ) - return self._parse_failure_outcome( - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - ) - - async def _upsert_success_or_exception( - self, - db_session: AsyncSession, - *, - run: CrawlRun, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], - has_partial_publication_set: bool, - ) -> ScholarProcessingOutcome: - try: - return await self._upsert_success( - db_session, - run=run, - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - has_partial_publication_set=has_partial_publication_set, - ) - except Exception as exc: - return self._upsert_exception_outcome( - run=run, - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - exc=exc, - ) - - async def _upsert_success( - self, - db_session: AsyncSession, - *, - run: CrawlRun, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], - has_partial_publication_set: bool, - ) -> ScholarProcessingOutcome: - discovered_count = await self._upsert_profile_publications( - db_session, - run=run, - scholar=scholar, - publications=paged_parse_result.publications, - ) - is_partial = ( - paged_parse_result.has_more_remaining - or paged_parse_result.pagination_truncated_reason is not None - or has_partial_publication_set - ) - scholar.last_run_status = RunStatus.PARTIAL_FAILURE if is_partial else RunStatus.SUCCESS - scholar.last_run_dt = run_dt - result_entry["outcome"] = "partial" if is_partial else "success" - if is_partial: - result_entry["debug"] = self._build_failure_debug_context( - fetch_result=paged_parse_result.fetch_result, - parsed_page=paged_parse_result.parsed_page, - attempt_log=paged_parse_result.attempt_log, - page_logs=paged_parse_result.page_logs, - ) - return ScholarProcessingOutcome( - result_entry=result_entry, - succeeded_count_delta=1, - failed_count_delta=0, - partial_count_delta=1 if is_partial else 0, - discovered_publication_count=discovered_count, - ) - - def _upsert_exception_outcome( - self, - *, - run: CrawlRun, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], - exc: Exception, - ) -> ScholarProcessingOutcome: - scholar.last_run_status = RunStatus.FAILED - scholar.last_run_dt = run_dt - result_entry["state"] = "ingestion_error" - result_entry["state_reason"] = "publication_upsert_exception" - result_entry["outcome"] = "failed" - result_entry["error"] = str(exc) - result_entry["debug"] = self._build_failure_debug_context( - fetch_result=paged_parse_result.fetch_result, - parsed_page=paged_parse_result.parsed_page, - attempt_log=paged_parse_result.attempt_log, - page_logs=paged_parse_result.page_logs, - exception=exc, - ) - logger.exception( - "ingestion.scholar_failed", - extra={ - "event": "ingestion.scholar_failed", - "crawl_run_id": run.id, - "scholar_profile_id": scholar.id, - "scholar_id": scholar.scholar_id, - }, - ) - return ScholarProcessingOutcome(result_entry, 0, 1, 0, 0) - - def _parse_failure_outcome( - self, - *, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], - ) -> ScholarProcessingOutcome: - scholar.last_run_status = RunStatus.FAILED - scholar.last_run_dt = run_dt - result_entry["debug"] = self._build_failure_debug_context( - fetch_result=paged_parse_result.fetch_result, - parsed_page=paged_parse_result.parsed_page, - attempt_log=paged_parse_result.attempt_log, - page_logs=paged_parse_result.page_logs, - ) - logger.warning( - "ingestion.scholar_parse_failed", - extra={ - "event": "ingestion.scholar_parse_failed", - "scholar_profile_id": scholar.id, - "scholar_id": scholar.scholar_id, - "state": paged_parse_result.parsed_page.state.value, - "state_reason": paged_parse_result.parsed_page.state_reason, - "status_code": paged_parse_result.fetch_result.status_code, - }, - ) - return ScholarProcessingOutcome(result_entry, 0, 1, 0, 0) - - async def _sync_continuation_queue( - self, - db_session: AsyncSession, - *, - user_id: int, - scholar: ScholarProfile, - run: CrawlRun, - start_cstart: int, - result_entry: dict[str, Any], - paged_parse_result: PagedParseResult, - auto_queue_continuations: bool, - queue_delay_seconds: int, - ) -> None: - queue_reason, queue_cstart = self._resolve_continuation_queue_target( - outcome=str(result_entry.get("outcome", "")), - state=str(result_entry.get("state", "")), - pagination_truncated_reason=paged_parse_result.pagination_truncated_reason, - continuation_cstart=paged_parse_result.continuation_cstart, - fallback_cstart=start_cstart, - ) - if auto_queue_continuations and queue_reason is not None: - await queue_service.upsert_job( - db_session, - user_id=user_id, - scholar_profile_id=scholar.id, - resume_cstart=queue_cstart, - reason=queue_reason, - run_id=run.id, - delay_seconds=queue_delay_seconds, - ) - result_entry["continuation_enqueued"] = True - result_entry["continuation_reason"] = queue_reason - result_entry["continuation_cstart"] = queue_cstart - return - if await queue_service.clear_job_for_scholar(db_session, user_id=user_id, scholar_profile_id=scholar.id): - result_entry["continuation_cleared"] = True - - async def _process_scholar( - self, - db_session: AsyncSession, - *, - run: CrawlRun, - scholar: ScholarProfile, - user_id: int, - request_delay_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - max_pages_per_scholar: int, - page_size: int, - start_cstart: int, - auto_queue_continuations: bool, - queue_delay_seconds: int, - ) -> ScholarProcessingOutcome: - try: - return await self._process_scholar_inner( - db_session, - run=run, - scholar=scholar, - user_id=user_id, - request_delay_seconds=request_delay_seconds, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - max_pages_per_scholar=max_pages_per_scholar, - page_size=page_size, - start_cstart=start_cstart, - auto_queue_continuations=auto_queue_continuations, - queue_delay_seconds=queue_delay_seconds, - ) - except Exception as exc: - return self._unexpected_scholar_exception_outcome( - run=run, - scholar=scholar, - start_cstart=start_cstart, - exc=exc, - ) - - async def _process_scholar_inner( - self, - db_session: AsyncSession, - *, - run: CrawlRun, - scholar: ScholarProfile, - user_id: int, - request_delay_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - max_pages_per_scholar: int, - page_size: int, - start_cstart: int, - auto_queue_continuations: bool, - queue_delay_seconds: int, - ) -> ScholarProcessingOutcome: - run_dt, paged_parse_result, result_entry = await self._fetch_and_prepare_scholar_result( - run=run, - scholar=scholar, - user_id=user_id, - start_cstart=start_cstart, - request_delay_seconds=request_delay_seconds, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - max_pages_per_scholar=max_pages_per_scholar, - page_size=page_size, - ) - outcome = await self._resolve_scholar_outcome( - db_session, - run=run, - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - ) - await self._sync_continuation_queue( - db_session, - user_id=user_id, - scholar=scholar, - run=run, - start_cstart=start_cstart, - result_entry=outcome.result_entry, - paged_parse_result=paged_parse_result, - auto_queue_continuations=auto_queue_continuations, - queue_delay_seconds=queue_delay_seconds, - ) - return outcome - - async def _fetch_and_prepare_scholar_result( - self, - *, - run: CrawlRun, - scholar: ScholarProfile, - user_id: int, - start_cstart: int, - request_delay_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - max_pages_per_scholar: int, - page_size: int, - ) -> tuple[datetime, PagedParseResult, dict[str, Any]]: - run_dt = datetime.now(timezone.utc) - paged_parse_result = await self._fetch_and_parse_all_pages_with_retry( - scholar_id=scholar.scholar_id, - start_cstart=start_cstart, - request_delay_seconds=request_delay_seconds, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - max_pages=max_pages_per_scholar, - page_size=page_size, - previous_initial_page_fingerprint_sha256=scholar.last_initial_page_fingerprint_sha256, - ) - self._assert_valid_paged_parse_result(scholar_id=scholar.scholar_id, paged_parse_result=paged_parse_result) - self._apply_first_page_profile_metadata(scholar=scholar, paged_parse_result=paged_parse_result, run_dt=run_dt) - self._log_scholar_parsed(user_id=user_id, run_id=run.id, scholar=scholar, paged_parse_result=paged_parse_result) - result_entry = self._build_result_entry( - scholar=scholar, - start_cstart=start_cstart, - paged_parse_result=paged_parse_result, - ) - return run_dt, paged_parse_result, result_entry - - async def _resolve_scholar_outcome( - self, - db_session: AsyncSession, - *, - run: CrawlRun, - scholar: ScholarProfile, - run_dt: datetime, - paged_parse_result: PagedParseResult, - result_entry: dict[str, Any], - ) -> ScholarProcessingOutcome: - if paged_parse_result.skipped_no_change: - return self._skipped_no_change_outcome( - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - ) - return await self._upsert_publications_outcome( - db_session, - run=run, - scholar=scholar, - run_dt=run_dt, - paged_parse_result=paged_parse_result, - result_entry=result_entry, - ) - - @staticmethod - def _apply_outcome_to_progress( - *, - progress: RunProgress, - run: CrawlRun, - outcome: ScholarProcessingOutcome, - ) -> None: - progress.succeeded_count += outcome.succeeded_count_delta - progress.failed_count += outcome.failed_count_delta - progress.partial_count += outcome.partial_count_delta - run.new_pub_count = int(run.new_pub_count or 0) + outcome.discovered_publication_count - progress.scholar_results.append(outcome.result_entry) - - def _unexpected_scholar_exception_outcome( - self, - *, - run: CrawlRun, - scholar: ScholarProfile, - start_cstart: int, - exc: Exception, - ) -> ScholarProcessingOutcome: - scholar.last_run_status = RunStatus.FAILED - scholar.last_run_dt = datetime.now(timezone.utc) - logger.exception( - "ingestion.scholar_unexpected_failure", - extra={ - "event": "ingestion.scholar_unexpected_failure", - "crawl_run_id": run.id, - "scholar_profile_id": scholar.id, - "scholar_id": scholar.scholar_id, - }, - ) - return ScholarProcessingOutcome( - result_entry={ - "scholar_profile_id": scholar.id, - "scholar_id": scholar.scholar_id, - "state": "ingestion_error", - "state_reason": "scholar_processing_exception", - "outcome": "failed", - "attempt_count": 0, - "publication_count": 0, - "start_cstart": start_cstart, - "warnings": [], - "error": str(exc), - "debug": {"exception_type": type(exc).__name__, "exception_message": str(exc)}, - }, - succeeded_count_delta=0, - failed_count_delta=1, - partial_count_delta=0, - discovered_publication_count=0, - ) - - @staticmethod - def _summarize_failures( - *, - scholar_results: list[dict[str, Any]], - ) -> RunFailureSummary: - failed_state_counts: dict[str, int] = {} - failed_reason_counts: dict[str, int] = {} - scrape_failure_counts: dict[str, int] = {} - retries_scheduled_count = 0 - scholars_with_retries_count = 0 - retry_exhausted_count = 0 - for entry in scholar_results: - retries_for_entry = max(0, _int_or_default(entry.get("attempt_count"), 0) - 1) - if retries_for_entry > 0: - retries_scheduled_count += retries_for_entry - scholars_with_retries_count += 1 - if str(entry.get("outcome", "")) != "failed": - continue - state = str(entry.get("state", "")).strip() - if state not in FAILED_STATES: - continue - failed_state_counts[state] = failed_state_counts.get(state, 0) + 1 - reason = str(entry.get("state_reason", "")).strip() - if reason: - failed_reason_counts[reason] = failed_reason_counts.get(reason, 0) + 1 - bucket = _classify_failure_bucket(state=state, state_reason=reason) - scrape_failure_counts[bucket] = scrape_failure_counts.get(bucket, 0) + 1 - if state == ParseState.NETWORK_ERROR.value and retries_for_entry > 0: - retry_exhausted_count += 1 - return RunFailureSummary( - failed_state_counts=failed_state_counts, - failed_reason_counts=failed_reason_counts, - scrape_failure_counts=scrape_failure_counts, - retries_scheduled_count=retries_scheduled_count, - scholars_with_retries_count=scholars_with_retries_count, - retry_exhausted_count=retry_exhausted_count, - ) - - @staticmethod - def _build_alert_summary( - *, - failure_summary: RunFailureSummary, - alert_blocked_failure_threshold: int, - alert_network_failure_threshold: int, - alert_retry_scheduled_threshold: int, - ) -> RunAlertSummary: - blocked_failure_count = int(failure_summary.scrape_failure_counts.get(FAILURE_BUCKET_BLOCKED, 0)) - network_failure_count = int(failure_summary.scrape_failure_counts.get(FAILURE_BUCKET_NETWORK, 0)) - blocked_threshold = max(1, int(alert_blocked_failure_threshold)) - network_threshold = max(1, int(alert_network_failure_threshold)) - retry_threshold = max(1, int(alert_retry_scheduled_threshold)) - alert_flags = { - "blocked_failure_threshold_exceeded": blocked_failure_count >= blocked_threshold, - "network_failure_threshold_exceeded": network_failure_count >= network_threshold, - "retry_scheduled_threshold_exceeded": failure_summary.retries_scheduled_count >= retry_threshold, - } - return RunAlertSummary( - blocked_failure_count=blocked_failure_count, - network_failure_count=network_failure_count, - blocked_failure_threshold=blocked_threshold, - network_failure_threshold=network_threshold, - retry_scheduled_threshold=retry_threshold, - alert_flags=alert_flags, - ) - - @staticmethod - def _log_alert_thresholds( - *, - user_id: int, - run_id: int, - failure_summary: RunFailureSummary, - alert_summary: RunAlertSummary, - ) -> None: - if alert_summary.alert_flags["blocked_failure_threshold_exceeded"]: - logger.warning( - "ingestion.alert_blocked_failure_threshold_exceeded", - extra={ - "event": "ingestion.alert_blocked_failure_threshold_exceeded", - "user_id": user_id, - "crawl_run_id": run_id, - "blocked_failure_count": alert_summary.blocked_failure_count, - "threshold": alert_summary.blocked_failure_threshold, - "metric_name": "ingestion_blocked_failure_threshold_exceeded_total", - "metric_value": 1, - }, - ) - if alert_summary.alert_flags["network_failure_threshold_exceeded"]: - logger.warning( - "ingestion.alert_network_failure_threshold_exceeded", - extra={ - "event": "ingestion.alert_network_failure_threshold_exceeded", - "user_id": user_id, - "crawl_run_id": run_id, - "network_failure_count": alert_summary.network_failure_count, - "threshold": alert_summary.network_failure_threshold, - "metric_name": "ingestion_network_failure_threshold_exceeded_total", - "metric_value": 1, - }, - ) - if alert_summary.alert_flags["retry_scheduled_threshold_exceeded"]: - logger.warning( - "ingestion.alert_retry_scheduled_threshold_exceeded", - extra={ - "event": "ingestion.alert_retry_scheduled_threshold_exceeded", - "user_id": user_id, - "crawl_run_id": run_id, - "retries_scheduled_count": failure_summary.retries_scheduled_count, - "threshold": alert_summary.retry_scheduled_threshold, - "metric_name": "ingestion_retry_scheduled_threshold_exceeded_total", - "metric_value": 1, - }, - ) - - @staticmethod - def _apply_safety_outcome( - *, - user_settings, - run: CrawlRun, - user_id: int, - alert_summary: RunAlertSummary, - ) -> None: - pre_apply_state = run_safety_service.get_safety_event_context( - user_settings, - now_utc=datetime.now(timezone.utc), - ) - safety_state, cooldown_trigger_reason = run_safety_service.apply_run_safety_outcome( - user_settings, - run_id=int(run.id), - blocked_failure_count=alert_summary.blocked_failure_count, - network_failure_count=alert_summary.network_failure_count, - blocked_failure_threshold=alert_summary.blocked_failure_threshold, - network_failure_threshold=alert_summary.network_failure_threshold, - blocked_cooldown_seconds=settings.ingestion_safety_cooldown_blocked_seconds, - network_cooldown_seconds=settings.ingestion_safety_cooldown_network_seconds, - now_utc=datetime.now(timezone.utc), - ) - ScholarIngestionService._log_safety_transition( - user_id=user_id, - run_id=int(run.id), - alert_summary=alert_summary, - pre_apply_state=pre_apply_state, - safety_state=safety_state, - cooldown_trigger_reason=cooldown_trigger_reason, - ) - - @staticmethod - def _log_safety_transition( - *, - user_id: int, - run_id: int, - alert_summary: RunAlertSummary, - pre_apply_state: dict[str, Any], - safety_state: dict[str, Any], - cooldown_trigger_reason: str | None, - ) -> None: - if cooldown_trigger_reason is not None: - logger.warning( - "ingestion.safety_cooldown_entered", - extra={ - "event": "ingestion.safety_cooldown_entered", - "user_id": user_id, - "crawl_run_id": run_id, - "reason": cooldown_trigger_reason, - "blocked_failure_count": alert_summary.blocked_failure_count, - "network_failure_count": alert_summary.network_failure_count, - "blocked_failure_threshold": alert_summary.blocked_failure_threshold, - "network_failure_threshold": alert_summary.network_failure_threshold, - "cooldown_until": safety_state.get("cooldown_until"), - "cooldown_remaining_seconds": safety_state.get("cooldown_remaining_seconds"), - "safety_counters": safety_state.get("counters", {}), - "metric_name": "ingestion_safety_cooldown_entered_total", - "metric_value": 1, - }, - ) - elif pre_apply_state.get("cooldown_active") and not safety_state.get("cooldown_active"): - logger.info( - "ingestion.safety_cooldown_cleared", - extra={ - "event": "ingestion.safety_cooldown_cleared", - "user_id": user_id, - "crawl_run_id": run_id, - "reason": pre_apply_state.get("cooldown_reason"), - "cooldown_until": pre_apply_state.get("cooldown_until"), - "metric_name": "ingestion_safety_cooldown_cleared_total", - "metric_value": 1, - }, - ) - - def _finalize_run_record( - self, - *, - run: CrawlRun, - scholars: list[ScholarProfile], - progress: RunProgress, - failure_summary: RunFailureSummary, - alert_summary: RunAlertSummary, - idempotency_key: str | None, - ) -> None: - run.end_dt = datetime.now(timezone.utc) - run.status = self._resolve_run_status( - scholar_count=len(scholars), - succeeded_count=progress.succeeded_count, - failed_count=progress.failed_count, - partial_count=progress.partial_count, - ) - run.error_log = { - "scholar_results": progress.scholar_results, - "summary": { - "succeeded_count": progress.succeeded_count, - "failed_count": progress.failed_count, - "partial_count": progress.partial_count, - "failed_state_counts": failure_summary.failed_state_counts, - "failed_reason_counts": failure_summary.failed_reason_counts, - "scrape_failure_counts": failure_summary.scrape_failure_counts, - "retry_counts": { - "retries_scheduled_count": failure_summary.retries_scheduled_count, - "scholars_with_retries_count": failure_summary.scholars_with_retries_count, - "retry_exhausted_count": failure_summary.retry_exhausted_count, - }, - "alert_thresholds": { - "blocked_failure_threshold": alert_summary.blocked_failure_threshold, - "network_failure_threshold": alert_summary.network_failure_threshold, - "retry_scheduled_threshold": alert_summary.retry_scheduled_threshold, - }, - "alert_flags": alert_summary.alert_flags, - }, - "meta": {"idempotency_key": idempotency_key} if idempotency_key else {}, - } - - @staticmethod - def _log_run_completed( - *, - user_id: int, - run: CrawlRun, - scholars: list[ScholarProfile], - progress: RunProgress, - alert_summary: RunAlertSummary, - failure_summary: RunFailureSummary, - ) -> None: - logger.info( - "ingestion.run_completed", - extra={ - "event": "ingestion.run_completed", - "user_id": user_id, - "crawl_run_id": run.id, - "status": run.status.value, - "scholar_count": len(scholars), - "succeeded_count": progress.succeeded_count, - "failed_count": progress.failed_count, - "partial_count": progress.partial_count, - "new_publication_count": run.new_pub_count, - "blocked_failure_count": alert_summary.blocked_failure_count, - "network_failure_count": alert_summary.network_failure_count, - "retries_scheduled_count": failure_summary.retries_scheduled_count, - "alert_flags": alert_summary.alert_flags, - }, - ) - - @staticmethod - def _paging_kwargs( - *, - request_delay_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - max_pages_per_scholar: int, - page_size: int, - ) -> dict[str, Any]: - return { - "request_delay_seconds": request_delay_seconds, - "network_error_retries": network_error_retries, - "retry_backoff_seconds": retry_backoff_seconds, - "max_pages_per_scholar": max_pages_per_scholar, - "page_size": page_size, - } - - @staticmethod - def _threshold_kwargs( - *, - alert_blocked_failure_threshold: int, - alert_network_failure_threshold: int, - alert_retry_scheduled_threshold: int, - ) -> dict[str, Any]: - return { - "alert_blocked_failure_threshold": alert_blocked_failure_threshold, - "alert_network_failure_threshold": alert_network_failure_threshold, - "alert_retry_scheduled_threshold": alert_retry_scheduled_threshold, - } - - @staticmethod - def _run_execution_summary( - *, - run: CrawlRun, - scholars: list[ScholarProfile], - progress: RunProgress, - ) -> RunExecutionSummary: - return RunExecutionSummary( - crawl_run_id=run.id, - status=run.status, - scholar_count=len(scholars), - succeeded_count=progress.succeeded_count, - failed_count=progress.failed_count, - partial_count=progress.partial_count, - new_publication_count=run.new_pub_count, - ) - - async def _initialize_run_for_user(self, db_session: AsyncSession, *, user_id: int, trigger_type: RunTriggerType, scholar_profile_ids: set[int] | None, start_cstart_by_scholar_id: dict[int, int] | None, request_delay_seconds: int, network_error_retries: int, retry_backoff_seconds: float, max_pages_per_scholar: int, page_size: int, idempotency_key: str | None, alert_blocked_failure_threshold: int, alert_network_failure_threshold: int, alert_retry_scheduled_threshold: int) -> tuple[Any, CrawlRun, list[ScholarProfile], dict[int, int]]: - user_settings = await self._load_user_settings_for_run( - db_session, - user_id=user_id, - trigger_type=trigger_type, - ) - if not await self._try_acquire_user_lock(db_session, user_id=user_id): - raise RunAlreadyInProgressError(f"Run already in progress for user_id={user_id}.") - filtered_scholar_ids, start_cstart_map = self._normalize_run_targets( - scholar_profile_ids=scholar_profile_ids, - start_cstart_by_scholar_id=start_cstart_by_scholar_id, - ) - scholars = await self._load_target_scholars( - db_session, - user_id=user_id, - filtered_scholar_ids=filtered_scholar_ids, - ) - run = await self._start_run_record_for_targets( - db_session, - user_id=user_id, - trigger_type=trigger_type, - scholars=scholars, - filtered=filtered_scholar_ids is not None, - request_delay_seconds=request_delay_seconds, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - max_pages_per_scholar=max_pages_per_scholar, - page_size=page_size, - idempotency_key=idempotency_key, - alert_blocked_failure_threshold=alert_blocked_failure_threshold, - alert_network_failure_threshold=alert_network_failure_threshold, - alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, - ) - return user_settings, run, scholars, start_cstart_map - - async def _start_run_record_for_targets( - self, - db_session: AsyncSession, - *, - user_id: int, - trigger_type: RunTriggerType, - scholars: list[ScholarProfile], - filtered: bool, - request_delay_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - max_pages_per_scholar: int, - page_size: int, - idempotency_key: str | None, - alert_blocked_failure_threshold: int, - alert_network_failure_threshold: int, - alert_retry_scheduled_threshold: int, - ) -> CrawlRun: - self._log_run_started( - user_id=user_id, - trigger_type=trigger_type, - scholar_count=len(scholars), - filtered=filtered, - request_delay_seconds=request_delay_seconds, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - max_pages_per_scholar=max_pages_per_scholar, - page_size=page_size, - idempotency_key=idempotency_key, - alert_blocked_failure_threshold=alert_blocked_failure_threshold, - alert_network_failure_threshold=alert_network_failure_threshold, - alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, - ) - run = self._create_running_run( - user_id=user_id, - trigger_type=trigger_type, - scholar_count=len(scholars), - idempotency_key=idempotency_key, - ) - db_session.add(run) - await db_session.flush() - return run - - async def _run_scholar_iteration( - self, - db_session: AsyncSession, - *, - run: CrawlRun, - scholars: list[ScholarProfile], - user_id: int, - start_cstart_map: dict[int, int], - request_delay_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - max_pages_per_scholar: int, - page_size: int, - auto_queue_continuations: bool, - queue_delay_seconds: int, - ) -> RunProgress: - progress = RunProgress() - for index, scholar in enumerate(scholars): - await self._wait_between_scholars(index=index, request_delay_seconds=request_delay_seconds) - start_cstart = int(start_cstart_map.get(int(scholar.id), 0)) - outcome = await self._process_scholar( - db_session, - run=run, - scholar=scholar, - user_id=user_id, - request_delay_seconds=request_delay_seconds, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - max_pages_per_scholar=max_pages_per_scholar, - page_size=page_size, - start_cstart=start_cstart, - auto_queue_continuations=auto_queue_continuations, - queue_delay_seconds=queue_delay_seconds, - ) - self._apply_outcome_to_progress(progress=progress, run=run, outcome=outcome) - return progress - - def _complete_run_for_user( - self, - *, - user_settings: Any, - run: CrawlRun, - scholars: list[ScholarProfile], - user_id: int, - progress: RunProgress, - idempotency_key: str | None, - alert_blocked_failure_threshold: int, - alert_network_failure_threshold: int, - alert_retry_scheduled_threshold: int, - ) -> tuple[RunFailureSummary, RunAlertSummary]: - failure_summary = self._summarize_failures(scholar_results=progress.scholar_results) - alert_summary = self._build_alert_summary( - failure_summary=failure_summary, - alert_blocked_failure_threshold=alert_blocked_failure_threshold, - alert_network_failure_threshold=alert_network_failure_threshold, - alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, - ) - self._log_alert_thresholds( - user_id=user_id, - run_id=int(run.id), - failure_summary=failure_summary, - alert_summary=alert_summary, - ) - self._apply_safety_outcome(user_settings=user_settings, run=run, user_id=user_id, alert_summary=alert_summary) - self._finalize_run_record( - run=run, - scholars=scholars, - progress=progress, - failure_summary=failure_summary, - alert_summary=alert_summary, - idempotency_key=idempotency_key, - ) - return failure_summary, alert_summary - - async def run_for_user(self, db_session: AsyncSession, *, user_id: int, trigger_type: RunTriggerType, request_delay_seconds: int, network_error_retries: int = 1, retry_backoff_seconds: float = 1.0, max_pages_per_scholar: int = 30, page_size: int = 100, scholar_profile_ids: set[int] | None = None, start_cstart_by_scholar_id: dict[int, int] | None = None, auto_queue_continuations: bool = True, queue_delay_seconds: int = 60, idempotency_key: str | None = None, alert_blocked_failure_threshold: int = 1, alert_network_failure_threshold: int = 2, alert_retry_scheduled_threshold: int = 3) -> RunExecutionSummary: - effective_request_delay_seconds = self._effective_request_delay_seconds(request_delay_seconds) - if effective_request_delay_seconds != _int_or_default(request_delay_seconds, effective_request_delay_seconds): - self._log_request_delay_coercion( - user_id=user_id, - requested_request_delay_seconds=_int_or_default(request_delay_seconds, 0), - effective_request_delay_seconds=effective_request_delay_seconds, - ) - paging_kwargs = self._paging_kwargs(request_delay_seconds=effective_request_delay_seconds, network_error_retries=network_error_retries, retry_backoff_seconds=retry_backoff_seconds, max_pages_per_scholar=max_pages_per_scholar, page_size=page_size) - threshold_kwargs = self._threshold_kwargs(alert_blocked_failure_threshold=alert_blocked_failure_threshold, alert_network_failure_threshold=alert_network_failure_threshold, alert_retry_scheduled_threshold=alert_retry_scheduled_threshold) - user_settings, run, scholars, start_cstart_map = await self._initialize_run_for_user( - db_session, - user_id=user_id, - trigger_type=trigger_type, - scholar_profile_ids=scholar_profile_ids, - start_cstart_by_scholar_id=start_cstart_by_scholar_id, - idempotency_key=idempotency_key, - **paging_kwargs, - **threshold_kwargs, - ) - progress = await self._run_scholar_iteration( - db_session, - run=run, - scholars=scholars, - user_id=user_id, - start_cstart_map=start_cstart_map, - auto_queue_continuations=auto_queue_continuations, - queue_delay_seconds=queue_delay_seconds, - **paging_kwargs, - ) - failure_summary, alert_summary = self._complete_run_for_user( - user_settings=user_settings, - run=run, - scholars=scholars, - user_id=user_id, - progress=progress, - idempotency_key=idempotency_key, - **threshold_kwargs, - ) - await db_session.commit() - self._log_run_completed(user_id=user_id, run=run, scholars=scholars, progress=progress, alert_summary=alert_summary, failure_summary=failure_summary) - return self._run_execution_summary(run=run, scholars=scholars, progress=progress) - - async def _fetch_profile_page( - self, - *, - scholar_id: str, - cstart: int, - page_size: int, - ) -> FetchResult: - try: - page_fetcher = getattr(self._source, "fetch_profile_page_html", None) - if callable(page_fetcher): - return await page_fetcher( - scholar_id, - cstart=cstart, - pagesize=page_size, - ) - if cstart <= 0: - return await self._source.fetch_profile_html(scholar_id) - return FetchResult( - requested_url=( - "https://scholar.google.com/citations" - f"?hl=en&user={scholar_id}&cstart={cstart}&pagesize={page_size}" - ), - status_code=None, - final_url=None, - body="", - error="source_does_not_support_pagination", - ) - except Exception as exc: - logger.exception( - "ingestion.fetch_unexpected_error", - extra={ - "event": "ingestion.fetch_unexpected_error", - "scholar_id": scholar_id, - "cstart": cstart, - "page_size": page_size, - }, - ) - return FetchResult( - requested_url=( - "https://scholar.google.com/citations" - f"?hl=en&user={scholar_id}&cstart={cstart}&pagesize={page_size}" - ), - status_code=None, - final_url=None, - body="", - error=str(exc), - ) - - @staticmethod - def _attempt_log_entry( - *, - attempt: int, - cstart: int, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - ) -> dict[str, Any]: - return { - "attempt": attempt, - "cstart": cstart, - "state": parsed_page.state.value, - "state_reason": parsed_page.state_reason, - "status_code": fetch_result.status_code, - "fetch_error": fetch_result.error, - } - - @staticmethod - def _should_retry_network_page( - *, - parsed_page: ParsedProfilePage, - attempt_index: int, - max_attempts: int, - ) -> bool: - return parsed_page.state == ParseState.NETWORK_ERROR and attempt_index < max_attempts - 1 - - @staticmethod - async def _sleep_retry_backoff( - *, - scholar_id: str, - cstart: int, - attempt_index: int, - backoff: float, - state_reason: str, - ) -> None: - sleep_seconds = backoff * (2**attempt_index) - logger.warning( - "ingestion.scholar_retry_scheduled", - extra={ - "event": "ingestion.scholar_retry_scheduled", - "scholar_id": scholar_id, - "cstart": cstart, - "attempt": attempt_index + 1, - "next_attempt": attempt_index + 2, - "sleep_seconds": sleep_seconds, - "state_reason": state_reason, - }, - ) - if sleep_seconds > 0: - await asyncio.sleep(sleep_seconds) - - async def _fetch_and_parse_page_with_retry( - self, - *, - scholar_id: str, - cstart: int, - page_size: int, - network_error_retries: int, - retry_backoff_seconds: float, - ) -> tuple[FetchResult, ParsedProfilePage, list[dict[str, Any]]]: - max_attempts = max(1, int(network_error_retries) + 1) - backoff = max(float(retry_backoff_seconds), 0.0) - attempt_log: list[dict[str, Any]] = [] - fetch_result: FetchResult | None = None - parsed_page: ParsedProfilePage | None = None - - for attempt_index in range(max_attempts): - fetch_result = await self._fetch_profile_page( - scholar_id=scholar_id, - cstart=cstart, - page_size=page_size, - ) - parsed_page = self._parse_profile_page_or_layout_error(fetch_result=fetch_result) - attempt_log.append( - self._attempt_log_entry( - attempt=attempt_index + 1, - cstart=cstart, - fetch_result=fetch_result, - parsed_page=parsed_page, - ) - ) - if not self._should_retry_network_page( - parsed_page=parsed_page, - attempt_index=attempt_index, - max_attempts=max_attempts, - ): - break - await self._sleep_retry_backoff( - scholar_id=scholar_id, - cstart=cstart, - attempt_index=attempt_index, - backoff=backoff, - state_reason=parsed_page.state_reason, - ) - - if fetch_result is None or parsed_page is None: - raise RuntimeError("Fetch-and-parse retry loop produced no result.") - return fetch_result, parsed_page, attempt_log - - def _parse_profile_page_or_layout_error( - self, - *, - fetch_result: FetchResult, - ) -> ParsedProfilePage: - try: - return parse_profile_page(fetch_result) - except ScholarParserError as exc: - return self._parsed_page_from_parser_error( - fetch_result=fetch_result, - code=exc.code, - ) - - @staticmethod - def _parsed_page_from_parser_error( - *, - fetch_result: FetchResult, - code: str, - ) -> ParsedProfilePage: - return ParsedProfilePage( - state=ParseState.LAYOUT_CHANGED, - state_reason=code, - profile_name=None, - profile_image_url=None, - publications=[], - marker_counts={}, - warnings=[code], - has_show_more_button=False, - has_operation_error_banner=False, - articles_range=None, - ) - - @staticmethod - def _page_log_entry( - *, - page_number: int, - cstart: int, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - attempt_count: int, - ) -> dict[str, Any]: - return { - "page": page_number, - "cstart": cstart, - "state": parsed_page.state.value, - "state_reason": parsed_page.state_reason, - "status_code": fetch_result.status_code, - "publication_count": len(parsed_page.publications), - "articles_range": parsed_page.articles_range, - "has_show_more_button": parsed_page.has_show_more_button, - "warning_codes": parsed_page.warnings, - "attempt_count": attempt_count, - } - - @staticmethod - def _should_skip_no_change( - *, - start_cstart: int, - first_page_fingerprint_sha256: str | None, - previous_initial_page_fingerprint_sha256: str | None, - parsed_page: ParsedProfilePage, - ) -> bool: - return ( - start_cstart <= 0 - and first_page_fingerprint_sha256 is not None - and previous_initial_page_fingerprint_sha256 is not None - and first_page_fingerprint_sha256 == previous_initial_page_fingerprint_sha256 - and parsed_page.state in {ParseState.OK, ParseState.NO_RESULTS} - ) - - @staticmethod - def _skip_no_change_result( - *, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - first_page_fingerprint_sha256: str | None, - attempt_log: list[dict[str, Any]], - page_logs: list[dict[str, Any]], - ) -> PagedParseResult: - return PagedParseResult( - fetch_result=fetch_result, - parsed_page=parsed_page, - first_page_fetch_result=fetch_result, - first_page_parsed_page=parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - publications=[], - attempt_log=attempt_log, - page_logs=page_logs, - pages_fetched=1, - pages_attempted=1, - has_more_remaining=False, - pagination_truncated_reason=None, - continuation_cstart=None, - skipped_no_change=True, - ) - - @staticmethod - def _initial_failure_result( - *, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - first_page_fingerprint_sha256: str | None, - start_cstart: int, - attempt_log: list[dict[str, Any]], - page_logs: list[dict[str, Any]], - ) -> PagedParseResult: - continuation_cstart = start_cstart if parsed_page.state == ParseState.NETWORK_ERROR else None - return PagedParseResult( - fetch_result=fetch_result, - parsed_page=parsed_page, - first_page_fetch_result=fetch_result, - first_page_parsed_page=parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - publications=[], - attempt_log=attempt_log, - page_logs=page_logs, - pages_fetched=0, - pages_attempted=1, - has_more_remaining=False, - pagination_truncated_reason=None, - continuation_cstart=continuation_cstart, - skipped_no_change=False, - ) - - @staticmethod - def _build_loop_state( - *, - start_cstart: int, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - attempt_log: list[dict[str, Any]], - page_logs: list[dict[str, Any]], - ) -> PagedLoopState: - next_cstart = _next_cstart_value( - articles_range=parsed_page.articles_range, - fallback=start_cstart + len(parsed_page.publications), - ) - return PagedLoopState( - fetch_result=fetch_result, - parsed_page=parsed_page, - attempt_log=attempt_log, - page_logs=page_logs, - publications=list(parsed_page.publications), - pages_fetched=1, - pages_attempted=1, - current_cstart=start_cstart, - next_cstart=next_cstart, - ) - - @staticmethod - def _set_truncated_state( - *, - state: PagedLoopState, - reason: str, - continuation_cstart: int, - ) -> None: - state.has_more_remaining = True - state.pagination_truncated_reason = reason - state.continuation_cstart = continuation_cstart - - def _should_stop_pagination(self, *, state: PagedLoopState, bounded_max_pages: int) -> bool: - if state.pages_fetched >= bounded_max_pages: - self._set_truncated_state( - state=state, - reason="max_pages_reached", - continuation_cstart=( - state.next_cstart if state.next_cstart > state.current_cstart else state.current_cstart - ), - ) - return True - if state.next_cstart <= state.current_cstart: - self._set_truncated_state( - state=state, - reason="pagination_cursor_stalled", - continuation_cstart=state.current_cstart, - ) - return True - return False - - async def _fetch_next_page( - self, - *, - scholar_id: str, - state: PagedLoopState, - request_delay_seconds: int, - bounded_page_size: int, - network_error_retries: int, - retry_backoff_seconds: float, - ) -> tuple[FetchResult, ParsedProfilePage, list[dict[str, Any]]]: - if request_delay_seconds > 0: - await asyncio.sleep(float(request_delay_seconds)) - state.current_cstart = state.next_cstart - return await self._fetch_and_parse_page_with_retry( - scholar_id=scholar_id, - cstart=state.current_cstart, - page_size=bounded_page_size, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - ) - - @staticmethod - def _record_next_page( - *, - state: PagedLoopState, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - page_attempt_log: list[dict[str, Any]], - ) -> None: - state.pages_attempted += 1 - state.attempt_log.extend(page_attempt_log) - state.page_logs.append( - ScholarIngestionService._page_log_entry( - page_number=state.pages_attempted, - cstart=state.current_cstart, - fetch_result=fetch_result, - parsed_page=parsed_page, - attempt_count=len(page_attempt_log), - ) - ) - state.fetch_result = fetch_result - state.parsed_page = parsed_page - - @staticmethod - def _handle_page_state_transition(*, state: PagedLoopState) -> bool: - if state.parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: - ScholarIngestionService._set_truncated_state( - state=state, - reason=f"page_state_{state.parsed_page.state.value}", - continuation_cstart=state.current_cstart, - ) - return True - if state.parsed_page.state == ParseState.NO_RESULTS and len(state.parsed_page.publications) == 0: - state.pages_fetched += 1 - return True - state.pages_fetched += 1 - state.publications.extend(state.parsed_page.publications) - state.next_cstart = _next_cstart_value( - articles_range=state.parsed_page.articles_range, - fallback=state.current_cstart + len(state.parsed_page.publications), - ) - return False - - async def _fetch_initial_page_context( - self, - *, - scholar_id: str, - start_cstart: int, - bounded_page_size: int, - network_error_retries: int, - retry_backoff_seconds: float, - ) -> tuple[FetchResult, ParsedProfilePage, str | None, list[dict[str, Any]], list[dict[str, Any]]]: - fetch_result, parsed_page, first_attempt_log = await self._fetch_and_parse_page_with_retry( - scholar_id=scholar_id, - cstart=start_cstart, - page_size=bounded_page_size, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - ) - first_page_fingerprint_sha256 = build_initial_page_fingerprint(parsed_page) - attempt_log = list(first_attempt_log) - page_logs = [ - self._page_log_entry( - page_number=1, - cstart=start_cstart, - fetch_result=fetch_result, - parsed_page=parsed_page, - attempt_count=len(first_attempt_log), - ) - ] - return fetch_result, parsed_page, first_page_fingerprint_sha256, attempt_log, page_logs - - async def _paginate_loop( - self, - *, - scholar_id: str, - state: PagedLoopState, - bounded_max_pages: int, - request_delay_seconds: int, - bounded_page_size: int, - network_error_retries: int, - retry_backoff_seconds: float, - ) -> None: - while state.parsed_page.has_show_more_button: - if self._should_stop_pagination(state=state, bounded_max_pages=bounded_max_pages): - return - next_fetch_result, next_parsed_page, next_attempt_log = await self._fetch_next_page( - scholar_id=scholar_id, - state=state, - request_delay_seconds=request_delay_seconds, - bounded_page_size=bounded_page_size, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - ) - self._record_next_page( - state=state, - fetch_result=next_fetch_result, - parsed_page=next_parsed_page, - page_attempt_log=next_attempt_log, - ) - if self._handle_page_state_transition(state=state): - return - - @staticmethod - def _result_from_pagination_state( - *, - state: PagedLoopState, - first_page_fetch_result: FetchResult, - first_page_parsed_page: ParsedProfilePage, - first_page_fingerprint_sha256: str | None, - ) -> PagedParseResult: - return PagedParseResult( - fetch_result=state.fetch_result, - parsed_page=state.parsed_page, - first_page_fetch_result=first_page_fetch_result, - first_page_parsed_page=first_page_parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - publications=_dedupe_publication_candidates(state.publications), - attempt_log=state.attempt_log, - page_logs=state.page_logs, - pages_fetched=state.pages_fetched, - pages_attempted=state.pages_attempted, - has_more_remaining=state.has_more_remaining, - pagination_truncated_reason=state.pagination_truncated_reason, - continuation_cstart=state.continuation_cstart, - skipped_no_change=False, - ) - - def _short_circuit_initial_page( - self, - *, - start_cstart: int, - previous_initial_page_fingerprint_sha256: str | None, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - first_page_fingerprint_sha256: str | None, - attempt_log: list[dict[str, Any]], - page_logs: list[dict[str, Any]], - ) -> PagedParseResult | None: - if self._should_skip_no_change( - start_cstart=start_cstart, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - previous_initial_page_fingerprint_sha256=previous_initial_page_fingerprint_sha256, - parsed_page=parsed_page, - ): - return self._skip_no_change_result( - fetch_result=fetch_result, - parsed_page=parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - attempt_log=attempt_log, - page_logs=page_logs, - ) - if parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: - return self._initial_failure_result( - fetch_result=fetch_result, - parsed_page=parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - start_cstart=start_cstart, - attempt_log=attempt_log, - page_logs=page_logs, - ) - return None - - async def _fetch_and_parse_all_pages_with_retry(self, *, scholar_id: str, start_cstart: int, request_delay_seconds: int, network_error_retries: int, retry_backoff_seconds: float, max_pages: int, page_size: int, previous_initial_page_fingerprint_sha256: str | None = None) -> PagedParseResult: - bounded_max_pages = max(1, int(max_pages)) - bounded_page_size = max(1, int(page_size)) - fetch_result, parsed_page, first_page_fingerprint_sha256, attempt_log, page_logs = ( - await self._fetch_initial_page_context( - scholar_id=scholar_id, - start_cstart=start_cstart, - bounded_page_size=bounded_page_size, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - )) - shortcut_result = self._short_circuit_initial_page( - start_cstart=start_cstart, - previous_initial_page_fingerprint_sha256=previous_initial_page_fingerprint_sha256, - fetch_result=fetch_result, - parsed_page=parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - attempt_log=attempt_log, - page_logs=page_logs, - ) - if shortcut_result is not None: - return shortcut_result - state = self._build_loop_state( - start_cstart=start_cstart, - fetch_result=fetch_result, - parsed_page=parsed_page, - attempt_log=attempt_log, - page_logs=page_logs, - ) - await self._paginate_loop( - scholar_id=scholar_id, - state=state, - bounded_max_pages=bounded_max_pages, - request_delay_seconds=request_delay_seconds, - bounded_page_size=bounded_page_size, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - ) - return self._result_from_pagination_state( - state=state, - first_page_fetch_result=fetch_result, - first_page_parsed_page=parsed_page, - first_page_fingerprint_sha256=first_page_fingerprint_sha256, - ) - - async def _upsert_profile_publications( - self, - db_session: AsyncSession, - *, - run: CrawlRun, - scholar: ScholarProfile, - publications: list[PublicationCandidate], - ) -> int: - seen_publication_ids: set[int] = set() - discovered_count = 0 - - for candidate in publications: - publication = await self._resolve_publication(db_session, candidate) - if publication.id in seen_publication_ids: - continue - seen_publication_ids.add(publication.id) - - link_result = await db_session.execute( - select(ScholarPublication).where( - ScholarPublication.scholar_profile_id == scholar.id, - ScholarPublication.publication_id == publication.id, - ) - ) - link = link_result.scalar_one_or_none() - if link is not None: - continue - - link = ScholarPublication( - scholar_profile_id=scholar.id, - publication_id=publication.id, - is_read=False, - first_seen_run_id=run.id, - ) - db_session.add(link) - discovered_count += 1 - - logger.debug( - "ingestion.publication_discovered", - extra={ - "event": "ingestion.publication_discovered", - "scholar_profile_id": scholar.id, - "publication_id": publication.id, - "crawl_run_id": run.id, - }, - ) - - if not scholar.baseline_completed: - scholar.baseline_completed = True - - return discovered_count - - @staticmethod - def _validate_publication_candidate(candidate: PublicationCandidate) -> None: - if not candidate.title.strip(): - raise RuntimeError("Publication candidate is missing title.") - if candidate.citation_count is not None and int(candidate.citation_count) < 0: - raise RuntimeError("Publication candidate has negative citation_count.") - - async def _find_publication_by_cluster( - self, - db_session: AsyncSession, - *, - cluster_id: str | None, - ) -> Publication | None: - if not cluster_id: - return None - result = await db_session.execute( - select(Publication).where(Publication.cluster_id == cluster_id) - ) - return result.scalar_one_or_none() - - async def _find_publication_by_fingerprint( - self, - db_session: AsyncSession, - *, - fingerprint: str, - ) -> Publication | None: - result = await db_session.execute( - select(Publication).where(Publication.fingerprint_sha256 == fingerprint) - ) - return result.scalar_one_or_none() - - @staticmethod - def _select_existing_publication( - *, - cluster_publication: Publication | None, - fingerprint_publication: Publication | None, - ) -> Publication | None: - if cluster_publication is not None: - return cluster_publication - return fingerprint_publication - - async def _create_publication( - self, - db_session: AsyncSession, - *, - candidate: PublicationCandidate, - fingerprint: str, - ) -> Publication: - publication = Publication( - cluster_id=candidate.cluster_id, - fingerprint_sha256=fingerprint, - title_raw=candidate.title, - title_normalized=normalize_title(candidate.title), - year=candidate.year, - citation_count=int(candidate.citation_count or 0), - author_text=candidate.authors_text, - venue_text=candidate.venue_text, - pub_url=build_publication_url(candidate.title_url), - doi=first_doi_from_texts(candidate.title_url, candidate.venue_text, candidate.title), - pdf_url=None, - ) - db_session.add(publication) - await db_session.flush() - logger.debug( - "ingestion.publication_created", - extra={ - "event": "ingestion.publication_created", - "publication_id": publication.id, - "cluster_id": publication.cluster_id, - }, - ) - return publication - - @staticmethod - def _update_existing_publication( - *, - publication: Publication, - candidate: PublicationCandidate, - ) -> None: - if candidate.cluster_id and publication.cluster_id is None: - publication.cluster_id = candidate.cluster_id - publication.title_raw = candidate.title - publication.title_normalized = normalize_title(candidate.title) - if candidate.year is not None: - publication.year = candidate.year - if candidate.citation_count is not None: - publication.citation_count = int(candidate.citation_count) - if candidate.authors_text: - publication.author_text = candidate.authors_text - if candidate.venue_text: - publication.venue_text = candidate.venue_text - if candidate.title_url: - publication.pub_url = build_publication_url(candidate.title_url) - local_doi = first_doi_from_texts(candidate.title_url, candidate.venue_text, candidate.title) - if local_doi and not publication.doi: - publication.doi = local_doi - - async def _resolve_publication( - self, - db_session: AsyncSession, - candidate: PublicationCandidate, - ) -> Publication: - self._validate_publication_candidate(candidate) - fingerprint = build_publication_fingerprint(candidate) - cluster_publication = await self._find_publication_by_cluster( - db_session, - cluster_id=candidate.cluster_id, - ) - fingerprint_publication = await self._find_publication_by_fingerprint( - db_session, - fingerprint=fingerprint, - ) - publication = self._select_existing_publication( - cluster_publication=cluster_publication, - fingerprint_publication=fingerprint_publication, - ) - if publication is None: - return await self._create_publication( - db_session, - candidate=candidate, - fingerprint=fingerprint, - ) - self._update_existing_publication( - publication=publication, - candidate=candidate, - ) - return publication - - def _resolve_run_status( - self, - *, - scholar_count: int, - succeeded_count: int, - failed_count: int, - partial_count: int, - ) -> RunStatus: - if scholar_count == 0: - return RunStatus.SUCCESS - if failed_count == scholar_count: - return RunStatus.FAILED - if failed_count > 0 or partial_count > 0: - return RunStatus.PARTIAL_FAILURE - if succeeded_count > 0: - return RunStatus.SUCCESS - return RunStatus.FAILED - - def _resolve_continuation_queue_target( - self, - *, - outcome: str, - state: str, - pagination_truncated_reason: str | None, - continuation_cstart: int | None, - fallback_cstart: int, - ) -> tuple[str | None, int]: - if outcome == "partial": - reason = (pagination_truncated_reason or "").strip() - if reason in RESUMABLE_PARTIAL_REASONS or reason.startswith( - RESUMABLE_PARTIAL_REASON_PREFIXES - ): - return reason, queue_service.normalize_cstart( - continuation_cstart if continuation_cstart is not None else fallback_cstart - ) - return None, queue_service.normalize_cstart(fallback_cstart) - - if outcome == "failed" and state == ParseState.NETWORK_ERROR.value: - return "network_error_retry", queue_service.normalize_cstart( - continuation_cstart if continuation_cstart is not None else fallback_cstart - ) - - return None, queue_service.normalize_cstart(fallback_cstart) - - def _build_failure_debug_context( - self, - *, - fetch_result: FetchResult, - parsed_page: ParsedProfilePage, - attempt_log: list[dict[str, Any]], - page_logs: list[dict[str, Any]] | None = None, - exception: Exception | None = None, - ) -> dict[str, Any]: - context: dict[str, Any] = { - "requested_url": fetch_result.requested_url, - "final_url": fetch_result.final_url, - "status_code": fetch_result.status_code, - "fetch_error": fetch_result.error, - "state_reason": parsed_page.state_reason, - "profile_name": parsed_page.profile_name, - "profile_image_url": parsed_page.profile_image_url, - "articles_range": parsed_page.articles_range, - "has_show_more_button": parsed_page.has_show_more_button, - "has_operation_error_banner": parsed_page.has_operation_error_banner, - "warning_codes": parsed_page.warnings, - "marker_counts_nonzero": { - key: value for key, value in parsed_page.marker_counts.items() if value > 0 - }, - "body_length": len(fetch_result.body), - "body_sha256": hashlib.sha256(fetch_result.body.encode("utf-8")).hexdigest() - if fetch_result.body - else None, - "body_excerpt": _build_body_excerpt(fetch_result.body), - "attempt_log": attempt_log, - } - if page_logs: - context["page_logs"] = page_logs - if exception is not None: - context["exception_type"] = type(exception).__name__ - context["exception_message"] = str(exception) - return context - - async def _try_acquire_user_lock( - self, - db_session: AsyncSession, - *, - user_id: int, - ) -> bool: - result = await db_session.execute( - text( - "SELECT pg_try_advisory_xact_lock(:namespace, :user_key)" - ), - { - "namespace": RUN_LOCK_NAMESPACE, - "user_key": int(user_id), - }, - ) - return bool(result.scalar_one()) diff --git a/app/services/domains/ingestion/fingerprints.py b/app/services/domains/ingestion/fingerprints.py deleted file mode 100644 index cf3e796..0000000 --- a/app/services/domains/ingestion/fingerprints.py +++ /dev/null @@ -1,136 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import re -from typing import Any -from urllib.parse import urljoin - -from app.services.domains.ingestion.constants import ( - HTML_TAG_RE, - INITIAL_PAGE_FINGERPRINT_MAX_PUBLICATIONS, - SPACE_RE, - TITLE_ALNUM_RE, - WORD_RE, -) -from app.services.domains.scholar.parser import ParseState, ParsedProfilePage, PublicationCandidate - - -def normalize_title(value: str) -> str: - lowered = value.lower() - return TITLE_ALNUM_RE.sub("", lowered) - - -def _first_author_last_name(authors_text: str | None) -> str: - if not authors_text: - return "" - first_author = authors_text.split(",", maxsplit=1)[0].strip().lower() - words = WORD_RE.findall(first_author) - if not words: - return "" - return words[-1] - - -def _first_venue_word(venue_text: str | None) -> str: - if not venue_text: - return "" - words = WORD_RE.findall(venue_text.lower()) - if not words: - return "" - return words[0] - - -def build_publication_fingerprint(candidate: PublicationCandidate) -> str: - canonical = "|".join( - [ - normalize_title(candidate.title), - str(candidate.year) if candidate.year is not None else "", - _first_author_last_name(candidate.authors_text), - _first_venue_word(candidate.venue_text), - ] - ) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - -def build_initial_page_fingerprint(parsed_page: ParsedProfilePage) -> str | None: - if parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: - return None - - normalized_rows: list[dict[str, Any]] = [] - for publication in parsed_page.publications[:INITIAL_PAGE_FINGERPRINT_MAX_PUBLICATIONS]: - normalized_rows.append( - { - "cluster_id": publication.cluster_id or "", - "title_normalized": normalize_title(publication.title), - "year": publication.year, - "citation_count": publication.citation_count, - } - ) - - payload = { - "state": parsed_page.state.value, - "articles_range": parsed_page.articles_range or "", - "has_show_more_button": parsed_page.has_show_more_button, - "profile_name": parsed_page.profile_name or "", - "publications": normalized_rows, - } - canonical = json.dumps( - payload, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=True, - ) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - -def build_publication_url(path_or_url: str | None) -> str | None: - if not path_or_url: - return None - return urljoin("https://scholar.google.com", path_or_url) - - -def _next_cstart_value(*, articles_range: str | None, fallback: int) -> int: - if articles_range: - numbers = re.findall(r"\d+", articles_range) - if len(numbers) >= 2: - try: - return int(numbers[1]) - except ValueError: - pass - return int(fallback) - - -def _dedupe_publication_candidates( - publications: list[PublicationCandidate], -) -> list[PublicationCandidate]: - deduped: list[PublicationCandidate] = [] - seen: set[str] = set() - for publication in publications: - if publication.cluster_id: - identity = f"cluster:{publication.cluster_id}" - else: - identity = "|".join( - [ - "fallback", - normalize_title(publication.title), - str(publication.year) if publication.year is not None else "", - publication.authors_text or "", - publication.venue_text or "", - ] - ) - if identity in seen: - continue - seen.add(identity) - deduped.append(publication) - return deduped - - -def _build_body_excerpt(body: str, *, max_chars: int = 220) -> str | None: - if not body: - return None - flattened = SPACE_RE.sub(" ", HTML_TAG_RE.sub(" ", body)).strip() - if not flattened: - return None - if len(flattened) <= max_chars: - return flattened - return f"{flattened[:max_chars - 1]}..." diff --git a/app/services/domains/ingestion/scheduler.py b/app/services/domains/ingestion/scheduler.py deleted file mode 100644 index 9db0204..0000000 --- a/app/services/domains/ingestion/scheduler.py +++ /dev/null @@ -1,596 +0,0 @@ -from __future__ import annotations - -import asyncio -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -import logging - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ( - CrawlRun, - QueueItemStatus, - RunTriggerType, - ScholarProfile, - User, - UserSetting, -) -from app.db.session import get_session_factory -from app.services.domains.ingestion import queue as queue_service -from app.services.domains.ingestion.application import ( - RunAlreadyInProgressError, - RunBlockedBySafetyPolicyError, - ScholarIngestionService, -) -from app.services.domains.settings import application as user_settings_service -from app.services.domains.scholar.source import LiveScholarSource -from app.settings import settings - -logger = logging.getLogger(__name__) - - -def _request_delay_floor_seconds() -> int: - return user_settings_service.resolve_request_delay_minimum( - settings.ingestion_min_request_delay_seconds - ) - - -def _effective_request_delay_seconds(value: int | None) -> int: - floor = _request_delay_floor_seconds() - try: - parsed = int(value) if value is not None else floor - except (TypeError, ValueError): - parsed = floor - return max(floor, parsed) - - -@dataclass(frozen=True) -class _AutoRunCandidate: - user_id: int - run_interval_minutes: int - request_delay_seconds: int - cooldown_until: datetime | None - cooldown_reason: str | None - - -class SchedulerService: - def __init__( - self, - *, - enabled: bool, - tick_seconds: int, - network_error_retries: int, - retry_backoff_seconds: float, - max_pages_per_scholar: int, - page_size: int, - continuation_queue_enabled: bool, - continuation_base_delay_seconds: int, - continuation_max_delay_seconds: int, - continuation_max_attempts: int, - queue_batch_size: int, - ) -> None: - self._enabled = enabled - self._tick_seconds = max(5, int(tick_seconds)) - self._network_error_retries = max(0, int(network_error_retries)) - self._retry_backoff_seconds = max(0.0, float(retry_backoff_seconds)) - self._max_pages_per_scholar = max(1, int(max_pages_per_scholar)) - self._page_size = max(1, int(page_size)) - self._continuation_queue_enabled = bool(continuation_queue_enabled) - self._continuation_base_delay_seconds = max(1, int(continuation_base_delay_seconds)) - self._continuation_max_delay_seconds = max( - self._continuation_base_delay_seconds, - int(continuation_max_delay_seconds), - ) - self._continuation_max_attempts = max(1, int(continuation_max_attempts)) - self._queue_batch_size = max(1, int(queue_batch_size)) - self._task: asyncio.Task[None] | None = None - self._source = LiveScholarSource() - - async def start(self) -> None: - if not self._enabled: - logger.info( - "scheduler.disabled", - extra={ - "event": "scheduler.disabled", - }, - ) - return - if self._task is not None: - return - self._task = asyncio.create_task(self._run_loop(), name="scholarr-scheduler") - logger.info( - "scheduler.started", - extra={ - "event": "scheduler.started", - "tick_seconds": self._tick_seconds, - "network_error_retries": self._network_error_retries, - "retry_backoff_seconds": self._retry_backoff_seconds, - "max_pages_per_scholar": self._max_pages_per_scholar, - "page_size": self._page_size, - "continuation_queue_enabled": self._continuation_queue_enabled, - "continuation_base_delay_seconds": self._continuation_base_delay_seconds, - "continuation_max_delay_seconds": self._continuation_max_delay_seconds, - "continuation_max_attempts": self._continuation_max_attempts, - "queue_batch_size": self._queue_batch_size, - }, - ) - - async def stop(self) -> None: - if self._task is None: - return - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - finally: - self._task = None - logger.info("scheduler.stopped", extra={"event": "scheduler.stopped"}) - - async def _run_loop(self) -> None: - while True: - try: - await self._tick_once() - except asyncio.CancelledError: - raise - except Exception: - logger.exception( - "scheduler.tick_failed", - extra={ - "event": "scheduler.tick_failed", - }, - ) - await asyncio.sleep(float(self._tick_seconds)) - - async def _tick_once(self) -> None: - if self._continuation_queue_enabled: - await self._drain_continuation_queue() - candidates = await self._load_candidates() - if not candidates: - return - now = datetime.now(timezone.utc) - for candidate in candidates: - if not await self._is_due(candidate, now=now): - continue - await self._run_candidate(candidate) - - async def _load_candidate_rows(self) -> list[tuple]: - session_factory = get_session_factory() - async with session_factory() as session: - result = await session.execute( - select( - UserSetting.user_id, - UserSetting.run_interval_minutes, - UserSetting.request_delay_seconds, - UserSetting.scrape_cooldown_until, - UserSetting.scrape_cooldown_reason, - ) - .join(User, User.id == UserSetting.user_id) - .where(User.is_active.is_(True), UserSetting.auto_run_enabled.is_(True)) - .order_by(UserSetting.user_id.asc()) - ) - return result.all() - - @staticmethod - def _candidate_from_row(row: tuple, *, now_utc: datetime) -> _AutoRunCandidate | None: - user_id, run_interval_minutes, request_delay_seconds, cooldown_until, cooldown_reason = row - if cooldown_until is not None and cooldown_until.tzinfo is None: - cooldown_until = cooldown_until.replace(tzinfo=timezone.utc) - if cooldown_until is not None and cooldown_until > now_utc: - logger.info( - "scheduler.run_skipped_safety_cooldown_precheck", - extra={ - "event": "scheduler.run_skipped_safety_cooldown_precheck", - "user_id": int(user_id), - "reason": cooldown_reason, - "cooldown_until": cooldown_until, - "cooldown_remaining_seconds": int((cooldown_until - now_utc).total_seconds()), - "metric_name": "scheduler_run_skipped_safety_cooldown_total", - "metric_value": 1, - }, - ) - return None - return _AutoRunCandidate( - user_id=int(user_id), - run_interval_minutes=int(run_interval_minutes), - request_delay_seconds=_effective_request_delay_seconds(request_delay_seconds), - cooldown_until=cooldown_until, - cooldown_reason=(str(cooldown_reason).strip() if cooldown_reason else None), - ) - - async def _load_candidates(self) -> list[_AutoRunCandidate]: - if not settings.ingestion_automation_allowed: - return [] - rows = await self._load_candidate_rows() - now_utc = datetime.now(timezone.utc) - candidates: list[_AutoRunCandidate] = [] - for row in rows: - candidate = self._candidate_from_row(row, now_utc=now_utc) - if candidate is not None: - candidates.append(candidate) - return candidates - - async def _is_due(self, candidate: _AutoRunCandidate, *, now: datetime) -> bool: - session_factory = get_session_factory() - async with session_factory() as session: - result = await session.execute( - select(CrawlRun.start_dt) - .where( - CrawlRun.user_id == candidate.user_id, - ) - .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) - .limit(1) - ) - last_run = result.scalar_one_or_none() - - if last_run is None: - return True - - next_due_dt = last_run + timedelta( - minutes=candidate.run_interval_minutes - ) - return now >= next_due_dt - - async def _run_candidate_ingestion( - self, - *, - candidate: _AutoRunCandidate, - ): - session_factory = get_session_factory() - async with session_factory() as session: - ingestion = ScholarIngestionService(source=self._source) - try: - return await ingestion.run_for_user( - session, - user_id=candidate.user_id, - trigger_type=RunTriggerType.SCHEDULED, - request_delay_seconds=candidate.request_delay_seconds, - network_error_retries=self._network_error_retries, - retry_backoff_seconds=self._retry_backoff_seconds, - max_pages_per_scholar=self._max_pages_per_scholar, - page_size=self._page_size, - auto_queue_continuations=self._continuation_queue_enabled, - queue_delay_seconds=self._continuation_base_delay_seconds, - alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, - alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, - alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, - ) - except RunAlreadyInProgressError: - await session.rollback() - logger.info("scheduler.run_skipped_locked", extra={"event": "scheduler.run_skipped_locked", "user_id": candidate.user_id}) - return None - except RunBlockedBySafetyPolicyError as exc: - await session.rollback() - logger.info( - "scheduler.run_skipped_safety_cooldown", - extra={ - "event": "scheduler.run_skipped_safety_cooldown", - "user_id": candidate.user_id, - "reason": exc.safety_state.get("cooldown_reason"), - "cooldown_until": exc.safety_state.get("cooldown_until"), - "cooldown_remaining_seconds": exc.safety_state.get("cooldown_remaining_seconds"), - "metric_name": "scheduler_run_skipped_safety_cooldown_total", - "metric_value": 1, - }, - ) - return None - except Exception: - await session.rollback() - logger.exception("scheduler.run_failed", extra={"event": "scheduler.run_failed", "user_id": candidate.user_id}) - return None - - async def _run_candidate(self, candidate: _AutoRunCandidate) -> None: - run_summary = await self._run_candidate_ingestion(candidate=candidate) - if run_summary is None: - return - logger.info( - "scheduler.run_completed", - extra={ - "event": "scheduler.run_completed", - "user_id": candidate.user_id, - "run_id": run_summary.crawl_run_id, - "status": run_summary.status.value, - "scholar_count": run_summary.scholar_count, - "new_publication_count": run_summary.new_publication_count, - }, - ) - - async def _drain_continuation_queue(self) -> None: - now = datetime.now(timezone.utc) - session_factory = get_session_factory() - async with session_factory() as session: - jobs = await queue_service.list_due_jobs( - session, - now=now, - limit=self._queue_batch_size, - ) - for job in jobs: - await self._run_queue_job(job) - - async def _drop_queue_job_if_max_attempts( - self, - job: queue_service.ContinuationQueueJob, - ) -> bool: - if job.attempt_count < self._continuation_max_attempts: - return False - session_factory = get_session_factory() - async with session_factory() as session: - dropped = await queue_service.mark_dropped( - session, - job_id=job.id, - reason="max_attempts_reached", - ) - await session.commit() - if dropped is not None: - logger.warning( - "scheduler.queue_item_dropped_max_attempts", - extra={ - "event": "scheduler.queue_item_dropped_max_attempts", - "queue_item_id": job.id, - "user_id": job.user_id, - "scholar_profile_id": job.scholar_profile_id, - "attempt_count": job.attempt_count, - "max_attempts": self._continuation_max_attempts, - }, - ) - return True - - async def _mark_queue_job_retrying( - self, - job: queue_service.ContinuationQueueJob, - ) -> bool: - session_factory = get_session_factory() - async with session_factory() as session: - queue_item = await queue_service.mark_retrying(session, job_id=job.id) - await session.commit() - if queue_item is None: - return False - if queue_item.status == QueueItemStatus.DROPPED.value: - return False - return True - - async def _queue_job_has_available_scholar( - self, - job: queue_service.ContinuationQueueJob, - ) -> bool: - session_factory = get_session_factory() - async with session_factory() as session: - scholar_result = await session.execute( - select(ScholarProfile.id).where( - ScholarProfile.user_id == job.user_id, - ScholarProfile.id == job.scholar_profile_id, - ScholarProfile.is_enabled.is_(True), - ) - ) - scholar_id = scholar_result.scalar_one_or_none() - if scholar_id is not None: - return True - dropped = await queue_service.mark_dropped( - session, - job_id=job.id, - reason="scholar_unavailable", - ) - await session.commit() - if dropped is not None: - logger.info( - "scheduler.queue_item_dropped_scholar_unavailable", - extra={ - "event": "scheduler.queue_item_dropped_scholar_unavailable", - "queue_item_id": job.id, - "user_id": job.user_id, - "scholar_profile_id": job.scholar_profile_id, - }, - ) - return False - - async def _reschedule_queue_job_lock_active(self, job: queue_service.ContinuationQueueJob) -> None: - session_factory = get_session_factory() - async with session_factory() as recovery_session: - await queue_service.reschedule_job( - recovery_session, - job_id=job.id, - delay_seconds=max(self._tick_seconds, 15), - reason="user_run_lock_active", - error="run_already_in_progress", - ) - await recovery_session.commit() - logger.info( - "scheduler.queue_item_deferred_lock", - extra={"event": "scheduler.queue_item_deferred_lock", "queue_item_id": job.id, "user_id": job.user_id}, - ) - - async def _reschedule_queue_job_safety_cooldown( - self, - job: queue_service.ContinuationQueueJob, - exc: RunBlockedBySafetyPolicyError, - ) -> None: - cooldown_remaining_seconds = max( - self._tick_seconds, - int(exc.safety_state.get("cooldown_remaining_seconds") or 0), - ) - session_factory = get_session_factory() - async with session_factory() as recovery_session: - await queue_service.reschedule_job( - recovery_session, - job_id=job.id, - delay_seconds=max(self._tick_seconds, cooldown_remaining_seconds), - reason="scrape_safety_cooldown", - error=str(exc.message), - ) - await recovery_session.commit() - logger.info( - "scheduler.queue_item_deferred_safety_cooldown", - extra={ - "event": "scheduler.queue_item_deferred_safety_cooldown", - "queue_item_id": job.id, - "user_id": job.user_id, - "reason": exc.safety_state.get("cooldown_reason"), - "cooldown_remaining_seconds": cooldown_remaining_seconds, - "metric_name": "scheduler_queue_item_deferred_safety_cooldown_total", - "metric_value": 1, - }, - ) - - async def _reschedule_queue_job_after_exception( - self, - job: queue_service.ContinuationQueueJob, - *, - exc: Exception, - ) -> None: - session_factory = get_session_factory() - async with session_factory() as recovery_session: - queue_item = await queue_service.increment_attempt_count(recovery_session, job_id=job.id) - if queue_item is None: - await recovery_session.commit() - return - if int(queue_item.attempt_count) >= self._continuation_max_attempts: - await queue_service.mark_dropped( - recovery_session, - job_id=job.id, - reason="scheduler_exception_max_attempts", - error=str(exc), - ) - await recovery_session.commit() - logger.warning( - "scheduler.queue_item_dropped_after_exception", - extra={"event": "scheduler.queue_item_dropped_after_exception", "queue_item_id": job.id, "user_id": job.user_id, "attempt_count": queue_item.attempt_count}, - ) - return - delay_seconds = queue_service.compute_backoff_seconds( - base_seconds=self._continuation_base_delay_seconds, - attempt_count=int(queue_item.attempt_count), - max_seconds=self._continuation_max_delay_seconds, - ) - await queue_service.reschedule_job( - recovery_session, - job_id=job.id, - delay_seconds=delay_seconds, - reason="scheduler_exception", - error=str(exc), - ) - await recovery_session.commit() - logger.exception("scheduler.queue_item_run_failed", extra={"event": "scheduler.queue_item_run_failed", "queue_item_id": job.id, "user_id": job.user_id}) - - async def _run_ingestion_for_queue_job( - self, - job: queue_service.ContinuationQueueJob, - ): - session_factory = get_session_factory() - async with session_factory() as session: - request_delay_seconds = await self._load_request_delay_for_user(session, user_id=job.user_id) - ingestion = ScholarIngestionService(source=self._source) - try: - return await ingestion.run_for_user( - session, - user_id=job.user_id, - trigger_type=RunTriggerType.SCHEDULED, - request_delay_seconds=request_delay_seconds, - network_error_retries=self._network_error_retries, - retry_backoff_seconds=self._retry_backoff_seconds, - max_pages_per_scholar=self._max_pages_per_scholar, - page_size=self._page_size, - scholar_profile_ids={job.scholar_profile_id}, - start_cstart_by_scholar_id={job.scholar_profile_id: job.resume_cstart}, - auto_queue_continuations=self._continuation_queue_enabled, - queue_delay_seconds=self._continuation_base_delay_seconds, - alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, - alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, - alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, - ) - except RunAlreadyInProgressError: - await session.rollback() - await self._reschedule_queue_job_lock_active(job) - except RunBlockedBySafetyPolicyError as exc: - await session.rollback() - await self._reschedule_queue_job_safety_cooldown(job, exc) - except Exception as exc: - await session.rollback() - await self._reschedule_queue_job_after_exception(job, exc=exc) - return None - - @staticmethod - def _log_queue_item_resolved( - *, - event_name: str, - job: queue_service.ContinuationQueueJob, - run_summary, - attempt_count: int | None = None, - delay_seconds: int | None = None, - ) -> None: - payload = { - "event": event_name, - "queue_item_id": job.id, - "user_id": job.user_id, - "run_id": run_summary.crawl_run_id, - "status": run_summary.status.value, - } - if attempt_count is not None: - payload["attempt_count"] = attempt_count - if delay_seconds is not None: - payload["delay_seconds"] = delay_seconds - logger.info(event_name, extra=payload) - - async def _finalize_queue_job_after_run(self, job: queue_service.ContinuationQueueJob, run_summary) -> None: - session_factory = get_session_factory() - async with session_factory() as session: - if int(run_summary.failed_count) <= 0: - queue_item = await queue_service.reset_attempt_count(session, job_id=job.id) - await session.commit() - if queue_item is None: - self._log_queue_item_resolved(event_name="scheduler.queue_item_resolved", job=job, run_summary=run_summary) - return - self._log_queue_item_resolved( - event_name="scheduler.queue_item_progressed", - job=job, - run_summary=run_summary, - attempt_count=int(queue_item.attempt_count), - ) - return - queue_item = await queue_service.increment_attempt_count(session, job_id=job.id) - if queue_item is None: - await session.commit() - self._log_queue_item_resolved(event_name="scheduler.queue_item_resolved", job=job, run_summary=run_summary) - return - if int(queue_item.attempt_count) >= self._continuation_max_attempts: - await queue_service.mark_dropped(session, job_id=job.id, reason="max_attempts_after_run") - await session.commit() - logger.warning( - "scheduler.queue_item_dropped_max_attempts_after_run", - extra={"event": "scheduler.queue_item_dropped_max_attempts_after_run", "queue_item_id": job.id, "user_id": job.user_id, "attempt_count": queue_item.attempt_count, "run_id": run_summary.crawl_run_id, "status": run_summary.status.value}, - ) - return - delay_seconds = queue_service.compute_backoff_seconds(base_seconds=self._continuation_base_delay_seconds, attempt_count=int(queue_item.attempt_count), max_seconds=self._continuation_max_delay_seconds) - await queue_service.reschedule_job(session, job_id=job.id, delay_seconds=delay_seconds, reason=queue_item.reason, error=queue_item.last_error) - await session.commit() - self._log_queue_item_resolved( - event_name="scheduler.queue_item_rescheduled_failed", - job=job, - run_summary=run_summary, - attempt_count=int(queue_item.attempt_count), - delay_seconds=delay_seconds, - ) - - async def _run_queue_job(self, job: queue_service.ContinuationQueueJob) -> None: - if await self._drop_queue_job_if_max_attempts(job): - return - if not await self._mark_queue_job_retrying(job): - return - if not await self._queue_job_has_available_scholar(job): - return - run_summary = await self._run_ingestion_for_queue_job(job) - if run_summary is None: - return - await self._finalize_queue_job_after_run(job, run_summary) - - async def _load_request_delay_for_user( - self, - db_session: AsyncSession, - *, - user_id: int, - ) -> int: - result = await db_session.execute( - select(UserSetting.request_delay_seconds).where(UserSetting.user_id == user_id) - ) - delay = result.scalar_one_or_none() - return _effective_request_delay_seconds(delay) diff --git a/app/services/domains/portability/__init__.py b/app/services/domains/portability/__init__.py deleted file mode 100644 index 28aae8b..0000000 --- a/app/services/domains/portability/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from app.services.domains.portability.application import * diff --git a/app/services/domains/publications/__init__.py b/app/services/domains/publications/__init__.py deleted file mode 100644 index 371b02c..0000000 --- a/app/services/domains/publications/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from app.services.domains.publications.application import * diff --git a/app/services/domains/publications/pdf_queue.py b/app/services/domains/publications/pdf_queue.py deleted file mode 100644 index b420d46..0000000 --- a/app/services/domains/publications/pdf_queue.py +++ /dev/null @@ -1,877 +0,0 @@ -from __future__ import annotations - -import asyncio -from dataclasses import dataclass -from datetime import datetime, timezone -import logging - -from sqlalchemy import Select, func, literal, or_, select, union_all -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import ( - Publication, - PublicationPdfJob, - PublicationPdfJobEvent, - ScholarProfile, - ScholarPublication, - User, -) -from app.db.session import get_session_factory -from app.services.domains.publications.types import PublicationListItem -from app.services.domains.unpaywall.application import ( - FAILURE_RESOLUTION_EXCEPTION, - OaResolutionOutcome, - resolve_publication_oa_outcomes, -) -from app.settings import settings - -PDF_STATUS_UNTRACKED = "untracked" -PDF_STATUS_QUEUED = "queued" -PDF_STATUS_RUNNING = "running" -PDF_STATUS_RESOLVED = "resolved" -PDF_STATUS_FAILED = "failed" - -PDF_EVENT_QUEUED = "queued" -PDF_EVENT_ATTEMPT_STARTED = "attempt_started" -PDF_EVENT_RESOLVED = "resolved" -PDF_EVENT_FAILED = "failed" - -logger = logging.getLogger(__name__) -_scheduled_tasks: set[asyncio.Task[None]] = set() - - -@dataclass(frozen=True) -class PdfQueueListItem: - publication_id: int - title: str - doi: str | None - pdf_url: str | None - status: str - attempt_count: int - last_failure_reason: str | None - last_failure_detail: str | None - last_source: str | None - requested_by_user_id: int | None - requested_by_email: str | None - queued_at: datetime | None - last_attempt_at: datetime | None - resolved_at: datetime | None - updated_at: datetime - - -@dataclass(frozen=True) -class PdfRequeueResult: - publication_exists: bool - queued: bool - - -@dataclass(frozen=True) -class PdfBulkQueueResult: - requested_count: int - queued_count: int - - -@dataclass(frozen=True) -class PdfQueuePage: - items: list[PdfQueueListItem] - total_count: int - limit: int - offset: int - - -def _utcnow() -> datetime: - return datetime.now(timezone.utc) - - -def _publication_ids(rows: list[PublicationListItem]) -> list[int]: - return sorted({row.publication_id for row in rows}) - - -def _status_from_job(row: PublicationListItem, job: PublicationPdfJob | None) -> str: - if row.pdf_url: - return PDF_STATUS_RESOLVED - if job is None: - return PDF_STATUS_UNTRACKED - return job.status - - -def _item_from_row_and_job( - row: PublicationListItem, - job: PublicationPdfJob | None, -) -> PublicationListItem: - return PublicationListItem( - publication_id=row.publication_id, - scholar_profile_id=row.scholar_profile_id, - scholar_label=row.scholar_label, - title=row.title, - year=row.year, - citation_count=row.citation_count, - venue_text=row.venue_text, - pub_url=row.pub_url, - doi=row.doi, - pdf_url=row.pdf_url, - is_read=row.is_read, - is_favorite=row.is_favorite, - first_seen_at=row.first_seen_at, - is_new_in_latest_run=row.is_new_in_latest_run, - pdf_status=_status_from_job(row, job), - pdf_attempt_count=int(job.attempt_count) if job is not None else 0, - pdf_failure_reason=job.last_failure_reason if job is not None else None, - pdf_failure_detail=job.last_failure_detail if job is not None else None, - ) - - -def _queueable_rows( - rows: list[PublicationListItem], - *, - max_items: int, -) -> list[PublicationListItem]: - bounded = max(0, int(max_items)) - if bounded == 0: - return [] - candidates = [row for row in rows if not row.pdf_url] - return candidates[:bounded] - - -def _bounded_limit(limit: int, *, max_value: int = 500) -> int: - return max(1, min(int(limit), max_value)) - - -def _bounded_offset(offset: int) -> int: - return max(int(offset), 0) - - -def _auto_retry_interval_seconds() -> int: - return max(int(settings.pdf_auto_retry_interval_seconds), 1) - - -def _auto_retry_first_interval_seconds() -> int: - return max(int(settings.pdf_auto_retry_first_interval_seconds), 1) - - -def _auto_retry_max_attempts() -> int: - return max(int(settings.pdf_auto_retry_max_attempts), 1) - - -def _retry_interval_seconds_for_attempt_count(attempt_count: int) -> int: - if int(attempt_count) <= 1: - return _auto_retry_first_interval_seconds() - return _auto_retry_interval_seconds() - - -def _cooldown_active( - *, - last_attempt_at: datetime | None, - attempt_count: int, -) -> bool: - if last_attempt_at is None: - return False - elapsed = (_utcnow() - last_attempt_at).total_seconds() - return elapsed < _retry_interval_seconds_for_attempt_count(int(attempt_count)) - - -def _can_enqueue_job( - job: PublicationPdfJob | None, - *, - force_retry: bool, -) -> bool: - if job is None: - return True - if job.status in {PDF_STATUS_QUEUED, PDF_STATUS_RUNNING}: - return False - if force_retry: - return job.status in {PDF_STATUS_FAILED, PDF_STATUS_RESOLVED, PDF_STATUS_UNTRACKED} - if job.status == PDF_STATUS_RESOLVED: - return False - if int(job.attempt_count) >= _auto_retry_max_attempts(): - return False - if _cooldown_active( - last_attempt_at=job.last_attempt_at, - attempt_count=int(job.attempt_count), - ): - return False - return True - - -def _event_row( - *, - publication_id: int, - user_id: int | None, - event_type: str, - status: str | None, - source: str | None = None, - failure_reason: str | None = None, - message: str | None = None, -) -> PublicationPdfJobEvent: - return PublicationPdfJobEvent( - publication_id=publication_id, - user_id=user_id, - event_type=event_type, - status=status, - source=source, - failure_reason=failure_reason, - message=message, - ) - - -def _queued_job( - *, - publication_id: int, - user_id: int, -) -> PublicationPdfJob: - now = _utcnow() - return PublicationPdfJob( - publication_id=publication_id, - status=PDF_STATUS_QUEUED, - queued_at=now, - last_requested_by_user_id=user_id, - ) - - -def _mark_job_queued(job: PublicationPdfJob, *, user_id: int) -> None: - now = _utcnow() - job.status = PDF_STATUS_QUEUED - job.queued_at = now - job.last_requested_by_user_id = user_id - job.last_failure_reason = None - job.last_failure_detail = None - job.last_source = None - - -def _state_map(jobs: list[PublicationPdfJob]) -> dict[int, PublicationPdfJob]: - return {int(job.publication_id): job for job in jobs} - - -async def _jobs_for_publication_ids( - db_session: AsyncSession, - *, - publication_ids: list[int], -) -> dict[int, PublicationPdfJob]: - if not publication_ids: - return {} - result = await db_session.execute( - select(PublicationPdfJob).where(PublicationPdfJob.publication_id.in_(publication_ids)) - ) - return _state_map(list(result.scalars())) - - -async def overlay_pdf_job_state( - db_session: AsyncSession, - *, - rows: list[PublicationListItem], -) -> list[PublicationListItem]: - if not rows: - return [] - jobs = await _jobs_for_publication_ids( - db_session, - publication_ids=_publication_ids(rows), - ) - return [_item_from_row_and_job(row, jobs.get(row.publication_id)) for row in rows] - - -async def _enqueue_rows( - db_session: AsyncSession, - *, - user_id: int, - rows: list[PublicationListItem], - force_retry: bool, -) -> list[PublicationListItem]: - if not rows: - return [] - queued: list[PublicationListItem] = [] - jobs = await _jobs_for_publication_ids( - db_session, - publication_ids=_publication_ids(rows), - ) - for row in rows: - job = jobs.get(row.publication_id) - if not _can_enqueue_job(job, force_retry=force_retry): - continue - if job is None: - job = _queued_job(publication_id=row.publication_id, user_id=user_id) - jobs[row.publication_id] = job - db_session.add(job) - else: - _mark_job_queued(job, user_id=user_id) - db_session.add( - _event_row( - publication_id=row.publication_id, - user_id=user_id, - event_type=PDF_EVENT_QUEUED, - status=PDF_STATUS_QUEUED, - ) - ) - queued.append(row) - if queued: - await db_session.commit() - return queued - - -def _register_task(task: asyncio.Task[None]) -> None: - _scheduled_tasks.add(task) - - -def _drop_finished_task(task: asyncio.Task[None]) -> None: - _scheduled_tasks.discard(task) - try: - task.result() - except Exception: - logger.exception( - "publications.pdf_queue.task_failed", - extra={"event": "publications.pdf_queue.task_failed"}, - ) - - -async def _mark_attempt_started( - *, - publication_id: int, - user_id: int, -) -> None: - session_factory = get_session_factory() - async with session_factory() as db_session: - job = await db_session.get(PublicationPdfJob, publication_id) - if job is None: - job = _queued_job(publication_id=publication_id, user_id=user_id) - db_session.add(job) - job.status = PDF_STATUS_RUNNING - job.last_attempt_at = _utcnow() - job.attempt_count = int(job.attempt_count) + 1 - db_session.add( - _event_row( - publication_id=publication_id, - user_id=user_id, - event_type=PDF_EVENT_ATTEMPT_STARTED, - status=PDF_STATUS_RUNNING, - ) - ) - await db_session.commit() - - -def _failed_outcome( - *, - row: PublicationListItem, -) -> OaResolutionOutcome: - return OaResolutionOutcome( - publication_id=row.publication_id, - doi=row.doi, - pdf_url=None, - failure_reason=FAILURE_RESOLUTION_EXCEPTION, - source=None, - used_crossref=False, - ) - - -async def _fetch_outcome_for_row( - *, - row: PublicationListItem, - request_email: str | None, -) -> OaResolutionOutcome: - outcomes = await resolve_publication_oa_outcomes([row], request_email=request_email) - outcome = outcomes.get(row.publication_id) - if outcome is not None: - return outcome - return _failed_outcome(row=row) - - -def _apply_publication_update( - publication: Publication, - *, - doi: str | None, - pdf_url: str | None, -) -> None: - if doi and publication.doi != doi: - publication.doi = doi - if pdf_url and publication.pdf_url != pdf_url: - publication.pdf_url = pdf_url - - -def _apply_job_outcome(job: PublicationPdfJob, *, outcome: OaResolutionOutcome) -> None: - job.last_source = outcome.source - if outcome.pdf_url: - job.status = PDF_STATUS_RESOLVED - job.resolved_at = _utcnow() - job.last_failure_reason = None - job.last_failure_detail = None - return - job.status = PDF_STATUS_FAILED - job.last_failure_reason = outcome.failure_reason - job.last_failure_detail = outcome.failure_reason - - -def _result_event(outcome: OaResolutionOutcome) -> tuple[str, str]: - if outcome.pdf_url: - return PDF_EVENT_RESOLVED, PDF_STATUS_RESOLVED - return PDF_EVENT_FAILED, PDF_STATUS_FAILED - - -async def _persist_outcome( - *, - publication_id: int, - user_id: int, - outcome: OaResolutionOutcome, -) -> None: - session_factory = get_session_factory() - async with session_factory() as db_session: - publication = await db_session.get(Publication, publication_id) - job = await db_session.get(PublicationPdfJob, publication_id) - if publication is None or job is None: - return - _apply_publication_update(publication, doi=outcome.doi, pdf_url=outcome.pdf_url) - _apply_job_outcome(job, outcome=outcome) - event_type, status = _result_event(outcome) - db_session.add( - _event_row( - publication_id=publication_id, - user_id=user_id, - event_type=event_type, - status=status, - source=outcome.source, - failure_reason=outcome.failure_reason, - message=outcome.failure_reason, - ) - ) - await db_session.commit() - - -async def _resolve_publication_row( - *, - user_id: int, - request_email: str | None, - row: PublicationListItem, -) -> None: - await _mark_attempt_started(publication_id=row.publication_id, user_id=user_id) - try: - outcome = await _fetch_outcome_for_row(row=row, request_email=request_email) - except Exception as exc: # pragma: no cover - defensive network boundary - logger.warning( - "publications.pdf_queue.resolve_failed", - extra={ - "event": "publications.pdf_queue.resolve_failed", - "publication_id": row.publication_id, - "error": str(exc), - }, - ) - outcome = _failed_outcome(row=row) - await _persist_outcome( - publication_id=row.publication_id, - user_id=user_id, - outcome=outcome, - ) - - -async def _run_resolution_task( - *, - user_id: int, - request_email: str | None, - rows: list[PublicationListItem], -) -> None: - for row in rows: - await _resolve_publication_row( - user_id=user_id, - request_email=request_email, - row=row, - ) - - -def _schedule_rows( - *, - user_id: int, - request_email: str | None, - rows: list[PublicationListItem], -) -> None: - if not rows: - return - task = asyncio.create_task( - _run_resolution_task( - user_id=user_id, - request_email=request_email, - rows=rows, - ) - ) - _register_task(task) - task.add_done_callback(_drop_finished_task) - - -async def enqueue_missing_pdf_jobs( - db_session: AsyncSession, - *, - user_id: int, - request_email: str | None, - rows: list[PublicationListItem], - max_items: int, -) -> list[int]: - queueable = _queueable_rows(rows, max_items=max_items) - queued_rows = await _enqueue_rows( - db_session, - user_id=user_id, - rows=queueable, - force_retry=False, - ) - _schedule_rows(user_id=user_id, request_email=request_email, rows=queued_rows) - return [row.publication_id for row in queued_rows] - - -async def enqueue_retry_pdf_job( - db_session: AsyncSession, - *, - user_id: int, - request_email: str | None, - row: PublicationListItem, -) -> bool: - queued_rows = await _enqueue_rows( - db_session, - user_id=user_id, - rows=[row], - force_retry=True, - ) - _schedule_rows(user_id=user_id, request_email=request_email, rows=queued_rows) - return bool(queued_rows) - - -def _retry_item_label(display_name: str | None, scholar_id: str | None) -> str: - return str(display_name or scholar_id or "unknown") - - -def _retry_item_from_publication( - publication: Publication, - *, - link_row: tuple | None, -) -> PublicationListItem: - if link_row is None: - scholar_profile_id = 0 - scholar_label = "unknown" - is_read = True - first_seen_at = publication.created_at or _utcnow() - else: - scholar_profile_id = int(link_row[0]) - scholar_label = _retry_item_label(link_row[1], link_row[2]) - is_read = bool(link_row[3]) - first_seen_at = link_row[4] or publication.created_at or _utcnow() - return PublicationListItem( - publication_id=int(publication.id), - scholar_profile_id=scholar_profile_id, - scholar_label=scholar_label, - title=publication.title_raw, - year=publication.year, - citation_count=int(publication.citation_count or 0), - venue_text=publication.venue_text, - pub_url=publication.pub_url, - doi=publication.doi, - pdf_url=publication.pdf_url, - is_read=is_read, - first_seen_at=first_seen_at, - is_new_in_latest_run=False, - ) - - -async def _retry_item_for_publication_id( - db_session: AsyncSession, - *, - publication_id: int, -) -> PublicationListItem | None: - publication = await db_session.get(Publication, publication_id) - if publication is None: - return None - result = await db_session.execute( - select( - ScholarProfile.id, - ScholarProfile.display_name, - ScholarProfile.scholar_id, - ScholarPublication.is_read, - ScholarPublication.created_at, - ) - .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) - .where(ScholarPublication.publication_id == publication_id) - .order_by(ScholarPublication.created_at.asc()) - .limit(1) - ) - return _retry_item_from_publication(publication, link_row=result.one_or_none()) - - -async def enqueue_retry_pdf_job_for_publication_id( - db_session: AsyncSession, - *, - user_id: int, - request_email: str | None, - publication_id: int, -) -> PdfRequeueResult: - row = await _retry_item_for_publication_id( - db_session, - publication_id=publication_id, - ) - if row is None: - return PdfRequeueResult(publication_exists=False, queued=False) - queued = await enqueue_retry_pdf_job( - db_session, - user_id=user_id, - request_email=request_email, - row=row, - ) - return PdfRequeueResult(publication_exists=True, queued=queued) - - -def _queue_candidate_from_publication(publication: Publication) -> PublicationListItem: - return PublicationListItem( - publication_id=int(publication.id), - scholar_profile_id=0, - scholar_label="admin", - title=publication.title_raw, - year=publication.year, - citation_count=int(publication.citation_count or 0), - venue_text=publication.venue_text, - pub_url=publication.pub_url, - doi=publication.doi, - pdf_url=publication.pdf_url, - is_read=True, - first_seen_at=publication.created_at or _utcnow(), - is_new_in_latest_run=False, - ) - - -async def _missing_pdf_candidates( - db_session: AsyncSession, - *, - limit: int, -) -> list[PublicationListItem]: - bounded_limit = max(1, min(int(limit), 5000)) - result = await db_session.execute( - select(Publication) - .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) - .where(Publication.pdf_url.is_(None)) - .where( - or_( - PublicationPdfJob.publication_id.is_(None), - PublicationPdfJob.status.notin_([PDF_STATUS_QUEUED, PDF_STATUS_RUNNING]), - ) - ) - .order_by(Publication.updated_at.desc(), Publication.id.desc()) - .limit(bounded_limit) - ) - return [ - _queue_candidate_from_publication(publication) - for publication in result.scalars() - ] - - -async def enqueue_all_missing_pdf_jobs( - db_session: AsyncSession, - *, - user_id: int, - request_email: str | None, - limit: int = 1000, -) -> PdfBulkQueueResult: - candidates = await _missing_pdf_candidates(db_session, limit=limit) - queued_rows = await _enqueue_rows( - db_session, - user_id=user_id, - rows=candidates, - force_retry=True, - ) - _schedule_rows(user_id=user_id, request_email=request_email, rows=queued_rows) - return PdfBulkQueueResult( - requested_count=len(candidates), - queued_count=len(queued_rows), - ) - - -def _tracked_queue_select_base(*, status: str | None) -> Select[tuple]: - stmt = ( - select( - PublicationPdfJob.publication_id, - Publication.title_raw, - Publication.doi, - Publication.pdf_url, - PublicationPdfJob.status, - PublicationPdfJob.attempt_count, - PublicationPdfJob.last_failure_reason, - PublicationPdfJob.last_failure_detail, - PublicationPdfJob.last_source, - PublicationPdfJob.last_requested_by_user_id, - User.email, - PublicationPdfJob.queued_at, - PublicationPdfJob.last_attempt_at, - PublicationPdfJob.resolved_at, - PublicationPdfJob.updated_at, - ) - .join(Publication, Publication.id == PublicationPdfJob.publication_id) - .outerjoin(User, User.id == PublicationPdfJob.last_requested_by_user_id) - ) - if status: - stmt = stmt.where(PublicationPdfJob.status == status) - return stmt - - -def _tracked_queue_select(*, limit: int, offset: int, status: str | None) -> Select[tuple]: - return ( - _tracked_queue_select_base(status=status) - .order_by(PublicationPdfJob.updated_at.desc()) - .limit(_bounded_limit(limit)) - .offset(_bounded_offset(offset)) - ) - - -def _untracked_queue_select_base() -> Select[tuple]: - return ( - select( - Publication.id, - Publication.title_raw, - Publication.doi, - Publication.pdf_url, - literal(PDF_STATUS_UNTRACKED), - literal(0), - literal(None), - literal(None), - literal(None), - literal(None), - literal(None), - literal(None), - literal(None), - literal(None), - Publication.updated_at, - ) - .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) - .where(Publication.pdf_url.is_(None)) - .where(PublicationPdfJob.publication_id.is_(None)) - ) - - -def _untracked_queue_select(*, limit: int, offset: int) -> Select[tuple]: - return ( - _untracked_queue_select_base() - .order_by(Publication.updated_at.desc(), Publication.id.desc()) - .limit(_bounded_limit(limit)) - .offset(_bounded_offset(offset)) - ) - - -def _all_queue_select(*, limit: int, offset: int) -> Select[tuple]: - union_stmt = union_all( - _tracked_queue_select_base(status=None), - _untracked_queue_select_base(), - ).subquery() - return ( - select(union_stmt) - .order_by(union_stmt.c.updated_at.desc()) - .limit(_bounded_limit(limit)) - .offset(_bounded_offset(offset)) - ) - - -def _tracked_queue_count_select(*, status: str | None) -> Select[tuple]: - stmt = select(func.count()).select_from(PublicationPdfJob) - if status: - stmt = stmt.where(PublicationPdfJob.status == status) - return stmt - - -def _untracked_queue_count_select() -> Select[tuple]: - return ( - select(func.count()) - .select_from(Publication) - .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) - .where(Publication.pdf_url.is_(None)) - .where(PublicationPdfJob.publication_id.is_(None)) - ) - - -def _queue_item_from_row(row: tuple) -> PdfQueueListItem: - return PdfQueueListItem( - publication_id=int(row[0]), - title=str(row[1] or ""), - doi=row[2], - pdf_url=row[3], - status=str(row[4] or PDF_STATUS_UNTRACKED), - attempt_count=int(row[5] or 0), - last_failure_reason=row[6], - last_failure_detail=row[7], - last_source=row[8], - requested_by_user_id=int(row[9]) if row[9] is not None else None, - requested_by_email=row[10], - queued_at=row[11], - last_attempt_at=row[12], - resolved_at=row[13], - updated_at=row[14], - ) - - -async def list_pdf_queue_items( - db_session: AsyncSession, - *, - limit: int = 100, - offset: int = 0, - status: str | None = None, -) -> list[PdfQueueListItem]: - bounded_limit = _bounded_limit(limit) - bounded_offset = _bounded_offset(offset) - normalized_status = (status or "").strip().lower() or None - if normalized_status == PDF_STATUS_UNTRACKED: - result = await db_session.execute( - _untracked_queue_select( - limit=bounded_limit, - offset=bounded_offset, - ) - ) - return [_queue_item_from_row(row) for row in result.all()] - if normalized_status is None: - result = await db_session.execute( - _all_queue_select( - limit=bounded_limit, - offset=bounded_offset, - ) - ) - return [_queue_item_from_row(row) for row in result.all()] - result = await db_session.execute( - _tracked_queue_select( - limit=bounded_limit, - offset=bounded_offset, - status=normalized_status, - ) - ) - return [_queue_item_from_row(row) for row in result.all()] - - -async def count_pdf_queue_items( - db_session: AsyncSession, - *, - status: str | None = None, -) -> int: - normalized_status = (status or "").strip().lower() or None - if normalized_status == PDF_STATUS_UNTRACKED: - result = await db_session.execute(_untracked_queue_count_select()) - return int(result.scalar_one() or 0) - tracked_result = await db_session.execute( - _tracked_queue_count_select(status=normalized_status) - ) - tracked_count = int(tracked_result.scalar_one() or 0) - if normalized_status is not None: - return tracked_count - untracked_result = await db_session.execute(_untracked_queue_count_select()) - untracked_count = int(untracked_result.scalar_one() or 0) - return tracked_count + untracked_count - - -async def list_pdf_queue_page( - db_session: AsyncSession, - *, - limit: int = 100, - offset: int = 0, - status: str | None = None, -) -> PdfQueuePage: - bounded_limit = _bounded_limit(limit) - bounded_offset = _bounded_offset(offset) - items = await list_pdf_queue_items( - db_session, - limit=bounded_limit, - offset=bounded_offset, - status=status, - ) - total_count = await count_pdf_queue_items( - db_session, - status=status, - ) - return PdfQueuePage( - items=items, - total_count=total_count, - limit=bounded_limit, - offset=bounded_offset, - ) diff --git a/app/services/domains/runs/__init__.py b/app/services/domains/runs/__init__.py deleted file mode 100644 index 848f6e0..0000000 --- a/app/services/domains/runs/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from app.services.domains.runs.application import * diff --git a/app/services/domains/scholars/application.py b/app/services/domains/scholars/application.py deleted file mode 100644 index 8da041f..0000000 --- a/app/services/domains/scholars/application.py +++ /dev/null @@ -1,974 +0,0 @@ -from __future__ import annotations - -import asyncio -from dataclasses import replace -from datetime import datetime -from datetime import timedelta -from datetime import timezone -import logging -import os -import random -from uuid import uuid4 - -from sqlalchemy import delete, func, select, text -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import AuthorSearchCacheEntry, AuthorSearchRuntimeState, ScholarProfile -from app.services.domains.scholar.parser import ( - ParseState, - ParsedAuthorSearchPage, - ScholarParserError, - parse_author_search_page, - parse_profile_page, -) -from app.services.domains.scholar.source import ScholarSource -from app.services.domains.scholars.constants import ( - ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES, - AUTHOR_SEARCH_LOCK_KEY, - AUTHOR_SEARCH_LOCK_NAMESPACE, - AUTHOR_SEARCH_RUNTIME_STATE_KEY, - DEFAULT_AUTHOR_SEARCH_BLOCKED_CACHE_TTL_SECONDS, - DEFAULT_AUTHOR_SEARCH_CACHE_MAX_ENTRIES, - DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD, - DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD, - DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS, - DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS, - MAX_AUTHOR_SEARCH_LIMIT, - DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS, - DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD, - SEARCH_CACHED_BLOCK_REASON, - SEARCH_COOLDOWN_REASON, - SEARCH_DISABLED_REASON, -) -from app.services.domains.scholars.exceptions import ScholarServiceError -from app.services.domains.scholars.search_hints import ( - _merge_warnings, - _policy_blocked_author_search_result, - _trim_author_search_result, - resolve_profile_image, - scrape_state_hint, -) -from app.services.domains.scholars.uploads import ( - _ensure_upload_root, - _resolve_upload_path, - _safe_remove_upload, - resolve_upload_file_path, -) -from app.services.domains.scholars.validators import ( - normalize_display_name, - normalize_profile_image_url, - validate_scholar_id, -) -logger = logging.getLogger(__name__) - - -async def _acquire_author_search_lock(db_session: AsyncSession) -> None: - await db_session.execute( - text("SELECT pg_advisory_xact_lock(:namespace, :lock_key)"), - { - "namespace": AUTHOR_SEARCH_LOCK_NAMESPACE, - "lock_key": AUTHOR_SEARCH_LOCK_KEY, - }, - ) - - -async def _load_runtime_state_for_update( - db_session: AsyncSession, -) -> AuthorSearchRuntimeState: - result = await db_session.execute( - select(AuthorSearchRuntimeState) - .where(AuthorSearchRuntimeState.state_key == AUTHOR_SEARCH_RUNTIME_STATE_KEY) - .with_for_update() - ) - state = result.scalar_one_or_none() - if state is not None: - return state - - state = AuthorSearchRuntimeState(state_key=AUTHOR_SEARCH_RUNTIME_STATE_KEY) - db_session.add(state) - await db_session.flush() - return state - - -def _serialize_parsed_author_search_page(parsed: ParsedAuthorSearchPage) -> dict: - return { - "state": parsed.state.value, - "state_reason": parsed.state_reason, - "marker_counts": {str(key): int(value) for key, value in parsed.marker_counts.items()}, - "warnings": [str(value) for value in parsed.warnings], - "candidates": [ - { - "scholar_id": candidate.scholar_id, - "display_name": candidate.display_name, - "affiliation": candidate.affiliation, - "email_domain": candidate.email_domain, - "cited_by_count": candidate.cited_by_count, - "interests": [str(interest) for interest in candidate.interests], - "profile_url": candidate.profile_url, - "profile_image_url": candidate.profile_image_url, - } - for candidate in parsed.candidates - ], - } - - -def _payload_state(payload: dict[str, object]) -> ParseState | None: - state_raw = str(payload.get("state", "")).strip() - try: - return ParseState(state_raw) - except ValueError: - return None - - -def _payload_marker_counts(payload: dict[str, object]) -> dict[str, int]: - marker_counts_payload = payload.get("marker_counts") - if not isinstance(marker_counts_payload, dict): - return {} - parsed: dict[str, int] = {} - for key, value in marker_counts_payload.items(): - try: - parsed[str(key)] = int(value) - except (TypeError, ValueError): - continue - return parsed - - -def _payload_warnings(payload: dict[str, object]) -> list[str]: - warnings_payload = payload.get("warnings") - if not isinstance(warnings_payload, list): - return [] - return [str(value) for value in warnings_payload if isinstance(value, str)] - - -def _parse_optional_string(value: object) -> str | None: - if value is None: - return None - normalized = str(value).strip() - return normalized or None - - -def _parse_optional_int(value: object) -> int | None: - if isinstance(value, int): - return value - if isinstance(value, str) and value.strip(): - try: - return int(value) - except ValueError: - return None - return None - - -def _normalize_interests(value: object) -> list[str]: - if not isinstance(value, list): - return [] - return [str(item) for item in value if isinstance(item, str)] - - -def _deserialize_candidate_payload(value: object) -> dict[str, object] | None: - if not isinstance(value, dict): - return None - scholar_id = str(value.get("scholar_id", "")).strip() - display_name = str(value.get("display_name", "")).strip() - profile_url = str(value.get("profile_url", "")).strip() - if not scholar_id or not display_name or not profile_url: - return None - return { - "scholar_id": scholar_id, - "display_name": display_name, - "affiliation": _parse_optional_string(value.get("affiliation")), - "email_domain": _parse_optional_string(value.get("email_domain")), - "cited_by_count": _parse_optional_int(value.get("cited_by_count")), - "interests": _normalize_interests(value.get("interests")), - "profile_url": profile_url, - "profile_image_url": _parse_optional_string(value.get("profile_image_url")), - } - - -def _deserialize_candidates(payload: dict[str, object]) -> list[dict[str, object]]: - candidates_payload = payload.get("candidates") - if not isinstance(candidates_payload, list): - return [] - normalized: list[dict[str, object]] = [] - for value in candidates_payload: - candidate = _deserialize_candidate_payload(value) - if candidate is not None: - normalized.append(candidate) - return normalized - - -def _deserialize_parsed_author_search_page(payload: object) -> ParsedAuthorSearchPage | None: - if not isinstance(payload, dict): - return None - - state = _payload_state(payload) - if state is None: - return None - - marker_counts = _payload_marker_counts(payload) - warnings = _payload_warnings(payload) - from app.services.domains.scholar.parser import ScholarSearchCandidate - - normalized_candidates = _deserialize_candidates(payload) - - return ParsedAuthorSearchPage( - state=state, - state_reason=str(payload.get("state_reason", "")).strip() or "unknown", - candidates=[ - ScholarSearchCandidate( - scholar_id=item["scholar_id"], - display_name=item["display_name"], - affiliation=item["affiliation"], - email_domain=item["email_domain"], - cited_by_count=item["cited_by_count"], - interests=item["interests"], - profile_url=item["profile_url"], - profile_image_url=item["profile_image_url"], - ) - for item in normalized_candidates - ], - marker_counts=marker_counts, - warnings=warnings, - ) - - -async def _cache_get_author_search_result( - db_session: AsyncSession, - *, - query_key: str, - now_utc: datetime, -) -> ParsedAuthorSearchPage | None: - result = await db_session.execute( - select(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.query_key == query_key) - ) - entry = result.scalar_one_or_none() - if entry is None: - return None - expires_at = entry.expires_at - if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) - if expires_at <= now_utc: - await db_session.delete(entry) - return None - parsed = _deserialize_parsed_author_search_page(entry.payload) - if parsed is None: - await db_session.delete(entry) - return None - return parsed - - -async def _cache_set_author_search_result( - db_session: AsyncSession, - *, - query_key: str, - parsed: ParsedAuthorSearchPage, - ttl_seconds: float, - max_entries: int, - now_utc: datetime, -) -> None: - ttl = max(float(ttl_seconds), 0.0) - existing_result = await db_session.execute( - select(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.query_key == query_key) - ) - existing = existing_result.scalar_one_or_none() - - if ttl <= 0.0: - if existing is not None: - await db_session.delete(existing) - return - - expires_at = now_utc + timedelta(seconds=ttl) - payload = _serialize_parsed_author_search_page(parsed) - if existing is None: - db_session.add( - AuthorSearchCacheEntry( - query_key=query_key, - payload=payload, - expires_at=expires_at, - cached_at=now_utc, - updated_at=now_utc, - ) - ) - else: - existing.payload = payload - existing.expires_at = expires_at - existing.cached_at = now_utc - existing.updated_at = now_utc - - await _prune_author_search_cache(db_session, now_utc=now_utc, max_entries=max_entries) - - -async def _prune_author_search_cache( - db_session: AsyncSession, - *, - now_utc: datetime, - max_entries: int, -) -> None: - await db_session.execute( - delete(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.expires_at <= now_utc) - ) - bounded_max_entries = max(1, int(max_entries)) - count_result = await db_session.execute( - select(func.count()).select_from(AuthorSearchCacheEntry) - ) - entry_count = int(count_result.scalar_one() or 0) - overflow = max(0, entry_count - bounded_max_entries) - if overflow <= 0: - return - stale_keys_result = await db_session.execute( - select(AuthorSearchCacheEntry.query_key) - .order_by(AuthorSearchCacheEntry.cached_at.asc()) - .limit(overflow) - ) - stale_keys = [str(row[0]) for row in stale_keys_result.all()] - if stale_keys: - await db_session.execute( - delete(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.query_key.in_(stale_keys)) - ) - - -def _is_author_search_block_state(parsed: ParsedAuthorSearchPage) -> bool: - return parsed.state == ParseState.BLOCKED_OR_CAPTCHA - - -def _author_search_cooldown_remaining_seconds( - runtime_state: AuthorSearchRuntimeState, - now_utc: datetime, -) -> int: - cooldown_until = runtime_state.cooldown_until - if cooldown_until is None: - return 0 - if cooldown_until.tzinfo is None: - cooldown_until = cooldown_until.replace(tzinfo=timezone.utc) - remaining_seconds = int((cooldown_until - now_utc).total_seconds()) - return max(0, remaining_seconds) - - -async def list_scholars_for_user( - db_session: AsyncSession, - *, - user_id: int, -) -> list[ScholarProfile]: - result = await db_session.execute( - select(ScholarProfile) - .where(ScholarProfile.user_id == user_id) - .order_by(ScholarProfile.created_at.desc(), ScholarProfile.id.desc()) - ) - return list(result.scalars().all()) - - -async def create_scholar_for_user( - db_session: AsyncSession, - *, - user_id: int, - scholar_id: str, - display_name: str, - profile_image_url: str | None = None, -) -> ScholarProfile: - profile = ScholarProfile( - user_id=user_id, - scholar_id=validate_scholar_id(scholar_id), - display_name=normalize_display_name(display_name), - profile_image_url=normalize_profile_image_url(profile_image_url), - ) - db_session.add(profile) - try: - await db_session.commit() - except IntegrityError as exc: - await db_session.rollback() - raise ScholarServiceError("That scholar is already tracked for this account.") from exc - await db_session.refresh(profile) - return profile - - -async def get_user_scholar_by_id( - db_session: AsyncSession, - *, - user_id: int, - scholar_profile_id: int, -) -> ScholarProfile | None: - result = await db_session.execute( - select(ScholarProfile).where( - ScholarProfile.id == scholar_profile_id, - ScholarProfile.user_id == user_id, - ) - ) - return result.scalar_one_or_none() - - -async def toggle_scholar_enabled( - db_session: AsyncSession, - *, - profile: ScholarProfile, -) -> ScholarProfile: - profile.is_enabled = not profile.is_enabled - await db_session.commit() - await db_session.refresh(profile) - return profile - - -async def delete_scholar( - db_session: AsyncSession, - *, - profile: ScholarProfile, - upload_dir: str | None = None, -) -> None: - if upload_dir: - upload_root = _ensure_upload_root(upload_dir, create=True) - _safe_remove_upload(upload_root, profile.profile_image_upload_path) - - await db_session.delete(profile) - await db_session.commit() - - -def _normalize_author_search_inputs(query: str, limit: int) -> tuple[str, int, str]: - normalized_query = query.strip() - if len(normalized_query) < 2: - raise ScholarServiceError("Search query must be at least 2 characters.") - bounded_limit = max(1, min(int(limit), MAX_AUTHOR_SEARCH_LIMIT)) - return normalized_query, bounded_limit, normalized_query.casefold() - - -def _disabled_search_result(*, normalized_query: str, bounded_limit: int) -> ParsedAuthorSearchPage: - logger.warning( - "scholar_search.disabled_by_configuration", - extra={"event": "scholar_search.disabled_by_configuration", "query": normalized_query}, - ) - return _policy_blocked_author_search_result( - reason=SEARCH_DISABLED_REASON, - warning_codes=["author_search_disabled_by_configuration"], - limit=bounded_limit, - ) - - -def _normalize_runtime_cooldown_state( - runtime_state: AuthorSearchRuntimeState, - *, - now_utc: datetime, -) -> bool: - if runtime_state.cooldown_until is None: - return False - cooldown_until = runtime_state.cooldown_until - updated = False - if cooldown_until.tzinfo is None: - cooldown_until = cooldown_until.replace(tzinfo=timezone.utc) - runtime_state.cooldown_until = cooldown_until - updated = True - if now_utc < cooldown_until: - return updated - logger.info( - "scholar_search.cooldown_expired", - extra={"event": "scholar_search.cooldown_expired", "cooldown_until_utc": cooldown_until.isoformat()}, - ) - runtime_state.cooldown_until = None - runtime_state.cooldown_rejection_count = 0 - runtime_state.cooldown_alert_emitted = False - return True - - -def _cooldown_warning_codes( - *, - runtime_state: AuthorSearchRuntimeState, - cooldown_remaining_seconds: int, -) -> list[str]: - warning_codes = [ - "author_search_cooldown_active", - f"author_search_cooldown_remaining_{cooldown_remaining_seconds}s", - ] - if bool(runtime_state.cooldown_alert_emitted): - warning_codes.append("author_search_cooldown_alert_threshold_exceeded") - return warning_codes - - -def _emit_cooldown_threshold_alert( - *, - runtime_state: AuthorSearchRuntimeState, - normalized_query: str, - cooldown_rejection_alert_threshold: int, -) -> bool: - runtime_state.cooldown_rejection_count = int(runtime_state.cooldown_rejection_count) + 1 - threshold = max(1, int(cooldown_rejection_alert_threshold)) - if int(runtime_state.cooldown_rejection_count) < threshold: - return True - if bool(runtime_state.cooldown_alert_emitted): - return True - logger.error( - "scholar_search.cooldown_rejection_threshold_exceeded", - extra={ - "event": "scholar_search.cooldown_rejection_threshold_exceeded", - "query": normalized_query, - "cooldown_rejection_count": int(runtime_state.cooldown_rejection_count), - "threshold": threshold, - "cooldown_until_utc": runtime_state.cooldown_until.isoformat() if runtime_state.cooldown_until else None, - }, - ) - runtime_state.cooldown_alert_emitted = True - return True - - -def _cooldown_block_result( - *, - runtime_state: AuthorSearchRuntimeState, - normalized_query: str, - bounded_limit: int, - cooldown_rejection_alert_threshold: int, - cooldown_remaining_seconds: int, -) -> ParsedAuthorSearchPage: - _emit_cooldown_threshold_alert( - runtime_state=runtime_state, - normalized_query=normalized_query, - cooldown_rejection_alert_threshold=cooldown_rejection_alert_threshold, - ) - logger.warning( - "scholar_search.cooldown_active", - extra={ - "event": "scholar_search.cooldown_active", - "query": normalized_query, - "cooldown_remaining_seconds": cooldown_remaining_seconds, - "cooldown_until_utc": runtime_state.cooldown_until.isoformat() if runtime_state.cooldown_until else None, - }, - ) - return _policy_blocked_author_search_result( - reason=SEARCH_COOLDOWN_REASON, - warning_codes=_cooldown_warning_codes( - runtime_state=runtime_state, - cooldown_remaining_seconds=cooldown_remaining_seconds, - ), - limit=bounded_limit, - ) - - -async def _cache_hit_result( - db_session: AsyncSession, - *, - query_key: str, - now_utc: datetime, - normalized_query: str, - bounded_limit: int, -) -> ParsedAuthorSearchPage | None: - cached = await _cache_get_author_search_result( - db_session, - query_key=query_key, - now_utc=now_utc, - ) - if cached is None: - return None - logger.info( - "scholar_search.cache_hit", - extra={ - "event": "scholar_search.cache_hit", - "query": normalized_query, - "state": cached.state.value, - "state_reason": cached.state_reason, - }, - ) - state_reason_override = SEARCH_CACHED_BLOCK_REASON if _is_author_search_block_state(cached) else None - return _trim_author_search_result( - cached, - limit=bounded_limit, - extra_warnings=["author_search_served_from_cache"], - state_reason_override=state_reason_override, - ) - - -def _throttle_sleep_seconds( - *, - runtime_state: AuthorSearchRuntimeState, - now_utc: datetime, - min_interval_seconds: float, - interval_jitter_seconds: float, -) -> tuple[float, bool]: - updated = False - if runtime_state.last_live_request_at is None: - enforced_wait_seconds = 0.0 - else: - last_live_request_at = runtime_state.last_live_request_at - if last_live_request_at.tzinfo is None: - last_live_request_at = last_live_request_at.replace(tzinfo=timezone.utc) - runtime_state.last_live_request_at = last_live_request_at - updated = True - enforced_wait_seconds = ( - last_live_request_at + timedelta(seconds=max(float(min_interval_seconds), 0.0)) - now_utc - ).total_seconds() - jitter_seconds = random.uniform(0.0, max(float(interval_jitter_seconds), 0.0)) - return max(0.0, float(enforced_wait_seconds)) + jitter_seconds, updated - - -async def _wait_for_author_search_throttle( - *, - runtime_state: AuthorSearchRuntimeState, - normalized_query: str, - now_utc: datetime, - min_interval_seconds: float, - interval_jitter_seconds: float, -) -> bool: - sleep_seconds, updated = _throttle_sleep_seconds( - runtime_state=runtime_state, - now_utc=now_utc, - min_interval_seconds=min_interval_seconds, - interval_jitter_seconds=interval_jitter_seconds, - ) - if sleep_seconds <= 0.0: - return updated - logger.info( - "scholar_search.throttle_wait", - extra={"event": "scholar_search.throttle_wait", "query": normalized_query, "sleep_seconds": round(sleep_seconds, 3)}, - ) - await asyncio.sleep(sleep_seconds) - return True - - -async def _fetch_author_search_with_retries( - *, - source: ScholarSource, - normalized_query: str, - network_error_retries: int, - retry_backoff_seconds: float, -) -> tuple[ParsedAuthorSearchPage, int, list[str]]: - max_attempts = max(1, int(network_error_retries) + 1) - parsed: ParsedAuthorSearchPage | None = None - retry_warnings: list[str] = [] - retry_scheduled_count = 0 - for attempt_index in range(max_attempts): - fetch_result = await source.fetch_author_search_html(normalized_query, start=0) - try: - parsed = parse_author_search_page(fetch_result) - except ScholarParserError as exc: - parsed = ParsedAuthorSearchPage( - state=ParseState.LAYOUT_CHANGED, - state_reason=exc.code, - candidates=[], - marker_counts={}, - warnings=[exc.code], - ) - if parsed.state != ParseState.NETWORK_ERROR or attempt_index >= max_attempts - 1: - break - retry_warnings.append("network_retry_scheduled_for_author_search") - retry_scheduled_count += 1 - retry_sleep_seconds = max(float(retry_backoff_seconds), 0.0) * (2**attempt_index) - if retry_sleep_seconds > 0: - await asyncio.sleep(retry_sleep_seconds) - if parsed is None: - raise ScholarServiceError("Unable to complete scholar author search.") - return parsed, retry_scheduled_count, retry_warnings - - -def _with_retry_warnings( - parsed: ParsedAuthorSearchPage, - *, - retry_warnings: list[str], - retry_scheduled_count: int, - retry_alert_threshold: int, - normalized_query: str, -) -> ParsedAuthorSearchPage: - merged = replace(parsed, warnings=_merge_warnings(parsed.warnings, retry_warnings)) - threshold = max(1, int(retry_alert_threshold)) - if retry_scheduled_count < threshold: - return merged - logger.warning( - "scholar_search.retry_threshold_exceeded", - extra={ - "event": "scholar_search.retry_threshold_exceeded", - "query": normalized_query, - "retry_scheduled_count": retry_scheduled_count, - "threshold": threshold, - "final_state": merged.state.value, - "final_state_reason": merged.state_reason, - }, - ) - return replace( - merged, - warnings=_merge_warnings( - merged.warnings, - [f"author_search_retry_threshold_exceeded_{retry_scheduled_count}"], - ), - ) - - -def _apply_block_circuit_breaker( - *, - runtime_state: AuthorSearchRuntimeState, - merged_parsed: ParsedAuthorSearchPage, - cooldown_block_threshold: int, - cooldown_seconds: int, - normalized_query: str, -) -> ParsedAuthorSearchPage: - if not _is_author_search_block_state(merged_parsed): - runtime_state.consecutive_blocked_count = 0 - return merged_parsed - runtime_state.consecutive_blocked_count = int(runtime_state.consecutive_blocked_count) + 1 - logger.warning( - "scholar_search.block_detected", - extra={ - "event": "scholar_search.block_detected", - "query": normalized_query, - "state_reason": merged_parsed.state_reason, - "consecutive_blocked_count": int(runtime_state.consecutive_blocked_count), - }, - ) - if int(runtime_state.consecutive_blocked_count) < max(1, int(cooldown_block_threshold)): - return merged_parsed - runtime_state.cooldown_until = datetime.now(timezone.utc) + timedelta(seconds=max(60, int(cooldown_seconds))) - runtime_state.consecutive_blocked_count = 0 - runtime_state.cooldown_rejection_count = 0 - runtime_state.cooldown_alert_emitted = False - logger.error( - "scholar_search.cooldown_activated", - extra={ - "event": "scholar_search.cooldown_activated", - "query": normalized_query, - "cooldown_until_utc": runtime_state.cooldown_until.isoformat() if runtime_state.cooldown_until else None, - }, - ) - return replace( - merged_parsed, - warnings=_merge_warnings(merged_parsed.warnings, ["author_search_circuit_breaker_armed"]), - ) - - -def _resolve_author_search_cache_ttl_seconds( - *, - merged_parsed: ParsedAuthorSearchPage, - blocked_cache_ttl_seconds: int, - cache_ttl_seconds: int, -) -> int: - if _is_author_search_block_state(merged_parsed): - return min(max(1, int(blocked_cache_ttl_seconds)), max(1, int(cache_ttl_seconds))) - return max(1, int(cache_ttl_seconds)) - - -async def _load_locked_runtime_state( - db_session: AsyncSession, -) -> AuthorSearchRuntimeState: - await _acquire_author_search_lock(db_session) - return await _load_runtime_state_for_update(db_session) - - -async def _cooldown_or_cache_result( - db_session: AsyncSession, - *, - runtime_state: AuthorSearchRuntimeState, - query_key: str, - normalized_query: str, - bounded_limit: int, - cooldown_rejection_alert_threshold: int, -) -> tuple[ParsedAuthorSearchPage | None, bool]: - runtime_state_updated = _normalize_runtime_cooldown_state( - runtime_state, - now_utc=datetime.now(timezone.utc), - ) - cooldown_remaining_seconds = _author_search_cooldown_remaining_seconds( - runtime_state, - datetime.now(timezone.utc), - ) - if cooldown_remaining_seconds > 0: - return ( - _cooldown_block_result( - runtime_state=runtime_state, - normalized_query=normalized_query, - bounded_limit=bounded_limit, - cooldown_rejection_alert_threshold=cooldown_rejection_alert_threshold, - cooldown_remaining_seconds=cooldown_remaining_seconds, - ), - True, - ) - cached_result = await _cache_hit_result( - db_session, - query_key=query_key, - now_utc=datetime.now(timezone.utc), - normalized_query=normalized_query, - bounded_limit=bounded_limit, - ) - return cached_result, runtime_state_updated - - -async def _perform_live_author_search(db_session: AsyncSession, *, source: ScholarSource, runtime_state: AuthorSearchRuntimeState, normalized_query: str, query_key: str, network_error_retries: int, retry_backoff_seconds: float, min_interval_seconds: float, interval_jitter_seconds: float, retry_alert_threshold: int, cooldown_block_threshold: int, cooldown_seconds: int, blocked_cache_ttl_seconds: int, cache_ttl_seconds: int, cache_max_entries: int) -> tuple[ParsedAuthorSearchPage, bool]: - runtime_state_updated = await _wait_for_author_search_throttle( - runtime_state=runtime_state, - normalized_query=normalized_query, - now_utc=datetime.now(timezone.utc), - min_interval_seconds=min_interval_seconds, - interval_jitter_seconds=interval_jitter_seconds, - ) - parsed, retry_count, retry_warnings = await _fetch_author_search_with_retries( - source=source, - normalized_query=normalized_query, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - ) - runtime_state.last_live_request_at = datetime.now(timezone.utc) - merged = _with_retry_warnings( - parsed, - retry_warnings=retry_warnings, - retry_scheduled_count=retry_count, - retry_alert_threshold=retry_alert_threshold, - normalized_query=normalized_query, - ) - merged = _apply_block_circuit_breaker( - runtime_state=runtime_state, - merged_parsed=merged, - cooldown_block_threshold=cooldown_block_threshold, - cooldown_seconds=cooldown_seconds, - normalized_query=normalized_query, - ) - ttl_seconds = _resolve_author_search_cache_ttl_seconds( - merged_parsed=merged, - blocked_cache_ttl_seconds=blocked_cache_ttl_seconds, - cache_ttl_seconds=cache_ttl_seconds, - ) - await _cache_set_author_search_result( - db_session, - query_key=query_key, - parsed=merged, - ttl_seconds=float(ttl_seconds), - max_entries=cache_max_entries, - now_utc=datetime.now(timezone.utc), - ) - return merged, True - - -async def search_author_candidates(*, source: ScholarSource, db_session: AsyncSession, query: str, limit: int, network_error_retries: int = 1, retry_backoff_seconds: float = 1.0, search_enabled: bool = True, cache_ttl_seconds: int = 21_600, blocked_cache_ttl_seconds: int = DEFAULT_AUTHOR_SEARCH_BLOCKED_CACHE_TTL_SECONDS, cache_max_entries: int = DEFAULT_AUTHOR_SEARCH_CACHE_MAX_ENTRIES, min_interval_seconds: float = DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS, interval_jitter_seconds: float = DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS, cooldown_block_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD, cooldown_seconds: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS, retry_alert_threshold: int = DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD, cooldown_rejection_alert_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD) -> ParsedAuthorSearchPage: - normalized_query, bounded_limit, query_key = _normalize_author_search_inputs(query, limit) - if not search_enabled: - return _disabled_search_result( - normalized_query=normalized_query, - bounded_limit=bounded_limit, - ) - - runtime_state = await _load_locked_runtime_state(db_session) - early_result, runtime_state_updated = await _cooldown_or_cache_result( - db_session, - runtime_state=runtime_state, - query_key=query_key, - normalized_query=normalized_query, - bounded_limit=bounded_limit, - cooldown_rejection_alert_threshold=cooldown_rejection_alert_threshold, - ) - if early_result is not None: - await db_session.commit() - return early_result - - merged_parsed, live_runtime_updated = await _perform_live_author_search( - db_session, - source=source, - runtime_state=runtime_state, - normalized_query=normalized_query, - query_key=query_key, - network_error_retries=network_error_retries, - retry_backoff_seconds=retry_backoff_seconds, - min_interval_seconds=min_interval_seconds, - interval_jitter_seconds=interval_jitter_seconds, - retry_alert_threshold=retry_alert_threshold, - cooldown_block_threshold=cooldown_block_threshold, - cooldown_seconds=cooldown_seconds, - blocked_cache_ttl_seconds=blocked_cache_ttl_seconds, - cache_ttl_seconds=cache_ttl_seconds, - cache_max_entries=cache_max_entries, - ) - runtime_state_updated = runtime_state_updated or live_runtime_updated - if runtime_state_updated: - await db_session.commit() - return _trim_author_search_result(merged_parsed, limit=bounded_limit) - - -async def hydrate_profile_metadata( - db_session: AsyncSession, - *, - profile: ScholarProfile, - source: ScholarSource, -) -> ScholarProfile: - fetch_result = await source.fetch_profile_html(profile.scholar_id) - try: - parsed_page = parse_profile_page(fetch_result) - except ScholarParserError: - return profile - - if parsed_page.profile_name and not (profile.display_name or "").strip(): - profile.display_name = parsed_page.profile_name - if parsed_page.profile_image_url and not profile.profile_image_url: - profile.profile_image_url = parsed_page.profile_image_url - - await db_session.commit() - await db_session.refresh(profile) - return profile - - -async def set_profile_image_override_url( - db_session: AsyncSession, - *, - profile: ScholarProfile, - image_url: str | None, - upload_dir: str, -) -> ScholarProfile: - upload_root = _ensure_upload_root(upload_dir, create=True) - _safe_remove_upload(upload_root, profile.profile_image_upload_path) - - profile.profile_image_upload_path = None - profile.profile_image_override_url = normalize_profile_image_url(image_url) - - await db_session.commit() - await db_session.refresh(profile) - return profile - - -async def clear_profile_image_customization( - db_session: AsyncSession, - *, - profile: ScholarProfile, - upload_dir: str, -) -> ScholarProfile: - upload_root = _ensure_upload_root(upload_dir, create=True) - _safe_remove_upload(upload_root, profile.profile_image_upload_path) - - profile.profile_image_upload_path = None - profile.profile_image_override_url = None - - await db_session.commit() - await db_session.refresh(profile) - return profile - - -async def set_profile_image_upload( - db_session: AsyncSession, - *, - profile: ScholarProfile, - content_type: str | None, - image_bytes: bytes, - upload_dir: str, - max_upload_bytes: int, -) -> ScholarProfile: - normalized_content_type = (content_type or "").strip().lower() - extension = ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES.get(normalized_content_type) - if extension is None: - raise ScholarServiceError( - "Unsupported image type. Use JPEG, PNG, WEBP, or GIF." - ) - - if not image_bytes: - raise ScholarServiceError("Uploaded image file is empty.") - - if len(image_bytes) > max_upload_bytes: - raise ScholarServiceError( - f"Uploaded image exceeds {max_upload_bytes} bytes." - ) - - upload_root = _ensure_upload_root(upload_dir, create=True) - user_dir = upload_root / str(profile.user_id) - user_dir.mkdir(parents=True, exist_ok=True) - - filename = f"{profile.id}_{uuid4().hex}{extension}" - relative_path = os.path.join(str(profile.user_id), filename) - absolute_path = _resolve_upload_path(upload_root, relative_path) - absolute_path.write_bytes(image_bytes) - - old_path = profile.profile_image_upload_path - profile.profile_image_upload_path = relative_path - profile.profile_image_override_url = None - - await db_session.commit() - await db_session.refresh(profile) - - if old_path and old_path != relative_path: - _safe_remove_upload(upload_root, old_path) - - return profile diff --git a/app/services/domains/users/__init__.py b/app/services/domains/users/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/app/services/domains/users/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/app/services/domains/__init__.py b/app/services/ingestion/__init__.py similarity index 100% rename from app/services/domains/__init__.py rename to app/services/ingestion/__init__.py diff --git a/app/services/ingestion/application.py b/app/services/ingestion/application.py new file mode 100644 index 0000000..1a6293e --- /dev/null +++ b/app/services/ingestion/application.py @@ -0,0 +1,550 @@ +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ( + CrawlRun, + RunStatus, + RunTriggerType, + ScholarProfile, +) +from app.logging_utils import structured_log +from app.services.ingestion import queue as queue_service +from app.services.ingestion.constants import RUN_LOCK_NAMESPACE +from app.services.ingestion.enrichment import EnrichmentRunner +from app.services.ingestion.pagination import PaginationEngine +from app.services.ingestion.run_completion import ( + complete_run_for_user, + int_or_default, + log_run_completed, + run_execution_summary, +) +from app.services.ingestion.run_enrichment import ( + inline_enrich_and_finalize, + spawn_background_enrichment_task, +) +from app.services.ingestion.run_guards import ( + load_user_settings_for_run, + run_preflight_guard, +) +from app.services.ingestion.scholar_processing import run_scholar_iteration +from app.services.ingestion.types import ( + RunAlertSummary, + RunAlreadyInProgressError, + RunBlockedBySafetyPolicyError, # noqa: F401 (re-exported) + RunFailureSummary, + RunProgress, +) +from app.services.scholar.source import ScholarSource +from app.services.settings import application as user_settings_service +from app.settings import settings + +logger = logging.getLogger(__name__) +ACTIVE_RUN_INDEX_NAME = "uq_crawl_runs_user_active" + + +def _is_active_run_integrity_error(exc: IntegrityError) -> bool: + original_error = getattr(exc, "orig", None) + if ACTIVE_RUN_INDEX_NAME in str(exc): + return True + if original_error is None: + return False + if ACTIVE_RUN_INDEX_NAME in str(original_error): + return True + diagnostics = getattr(original_error, "diag", None) + if diagnostics is None: + return False + return getattr(diagnostics, "constraint_name", None) == ACTIVE_RUN_INDEX_NAME + + +def _resolve_paging_kwargs( + *, + request_delay_seconds: int, + network_error_retries: int, + retry_backoff_seconds: float, + rate_limit_retries: int | None, + rate_limit_backoff_seconds: float | None, + max_pages_per_scholar: int, + page_size: int, +) -> dict[str, Any]: + return { + "request_delay_seconds": request_delay_seconds, + "network_error_retries": network_error_retries, + "retry_backoff_seconds": retry_backoff_seconds, + "rate_limit_retries": rate_limit_retries + if rate_limit_retries is not None + else settings.ingestion_rate_limit_retries, + "rate_limit_backoff_seconds": rate_limit_backoff_seconds + if rate_limit_backoff_seconds is not None + else settings.ingestion_rate_limit_backoff_seconds, + "max_pages_per_scholar": max_pages_per_scholar, + "page_size": page_size, + } + + +def _threshold_kwargs( + *, + alert_blocked_failure_threshold: int, + alert_network_failure_threshold: int, + alert_retry_scheduled_threshold: int, +) -> dict[str, Any]: + return { + "alert_blocked_failure_threshold": alert_blocked_failure_threshold, + "alert_network_failure_threshold": alert_network_failure_threshold, + "alert_retry_scheduled_threshold": alert_retry_scheduled_threshold, + } + + +class ScholarIngestionService: + def __init__(self, *, source: ScholarSource) -> None: + self._source = source + self._pagination = PaginationEngine(source=source) + self._enrichment = EnrichmentRunner() + + @staticmethod + def _effective_request_delay_seconds(value: int) -> int: + policy_minimum = user_settings_service.resolve_request_delay_minimum( + settings.ingestion_min_request_delay_seconds + ) + return max(policy_minimum, int_or_default(value, policy_minimum)) + + async def _load_target_scholars( + self, + db_session: AsyncSession, + *, + user_id: int, + filtered_scholar_ids: set[int] | None, + ) -> list[ScholarProfile]: + scholars_stmt = ( + select(ScholarProfile) + .where(ScholarProfile.user_id == user_id, ScholarProfile.is_enabled.is_(True)) + .order_by(ScholarProfile.created_at.asc(), ScholarProfile.id.asc()) + ) + if filtered_scholar_ids is not None: + scholars_stmt = scholars_stmt.where(ScholarProfile.id.in_(filtered_scholar_ids)) + scholars_result = await db_session.execute(scholars_stmt) + scholars = list(scholars_result.scalars().all()) + if filtered_scholar_ids is not None: + found_ids = {int(s.id) for s in scholars} + for sid in filtered_scholar_ids - found_ids: + await queue_service.clear_job_for_scholar(db_session, user_id=user_id, scholar_profile_id=sid) + return scholars + + async def _initialize_run_for_user( + self, + db_session: AsyncSession, + *, + user_id: int, + trigger_type: RunTriggerType, + scholar_profile_ids: set[int] | None, + start_cstart_by_scholar_id: dict[int, int] | None, + paging_kwargs: dict[str, Any], + threshold_kwargs: dict[str, Any], + idempotency_key: str | None, + ) -> tuple[Any, CrawlRun, list[ScholarProfile], dict[int, int]]: + user_settings = await load_user_settings_for_run( + db_session, + user_id=user_id, + trigger_type=trigger_type, + ) + if not await self._try_acquire_user_lock(db_session, user_id=user_id): + raise RunAlreadyInProgressError(f"Run already in progress for user_id={user_id}.") + filtered_scholar_ids = {int(v) for v in scholar_profile_ids} if scholar_profile_ids is not None else None + start_cstart_map = {int(k): max(0, int(v)) for k, v in (start_cstart_by_scholar_id or {}).items()} + scholars = await self._load_target_scholars( + db_session, + user_id=user_id, + filtered_scholar_ids=filtered_scholar_ids, + ) + await run_preflight_guard( + db_session, + self._source, + user_settings=user_settings, + user_id=user_id, + scholars=scholars, + ) + run = await self._start_run_record( + db_session, + user_id=user_id, + trigger_type=trigger_type, + scholars=scholars, + filtered=filtered_scholar_ids is not None, + idempotency_key=idempotency_key, + paging_kwargs=paging_kwargs, + threshold_kwargs=threshold_kwargs, + ) + return user_settings, run, scholars, start_cstart_map + + async def _start_run_record( + self, + db_session: AsyncSession, + *, + user_id: int, + trigger_type: RunTriggerType, + scholars: list[ScholarProfile], + filtered: bool, + idempotency_key: str | None, + paging_kwargs: dict[str, Any], + threshold_kwargs: dict[str, Any], + ) -> CrawlRun: + structured_log( + logger, + "info", + "ingestion.run_started", + user_id=user_id, + trigger_type=trigger_type.value, + scholar_count=len(scholars), + is_filtered_run=filtered, + idempotency_key=idempotency_key, + **paging_kwargs, + **threshold_kwargs, + ) + run = CrawlRun( + user_id=user_id, + trigger_type=trigger_type, + status=RunStatus.RUNNING, + scholar_count=len(scholars), + new_pub_count=0, + idempotency_key=idempotency_key, + error_log={}, + ) + db_session.add(run) + try: + await db_session.flush() + except IntegrityError as exc: + if _is_active_run_integrity_error(exc): + await db_session.rollback() + raise RunAlreadyInProgressError(f"Run already in progress for user_id={user_id}.") from exc + raise + return run + + async def initialize_run( + self, + db_session: AsyncSession, + *, + user_id: int, + trigger_type: RunTriggerType, + request_delay_seconds: int, + network_error_retries: int = 1, + retry_backoff_seconds: float = 1.0, + rate_limit_retries: int | None = None, + rate_limit_backoff_seconds: float | None = None, + max_pages_per_scholar: int = 30, + page_size: int = 100, + scholar_profile_ids: set[int] | None = None, + start_cstart_by_scholar_id: dict[int, int] | None = None, + idempotency_key: str | None = None, + alert_blocked_failure_threshold: int = 1, + alert_network_failure_threshold: int = 2, + alert_retry_scheduled_threshold: int = 3, + ) -> tuple[CrawlRun, list[ScholarProfile], dict[int, int]]: + effective_delay = self._effective_request_delay_seconds(request_delay_seconds) + if effective_delay != int_or_default(request_delay_seconds, effective_delay): + structured_log( + logger, + "warning", + "ingestion.delay_coerced", + user_id=user_id, + requested_request_delay_seconds=int_or_default(request_delay_seconds, 0), + effective_request_delay_seconds=effective_delay, + policy_minimum_request_delay_seconds=user_settings_service.resolve_request_delay_minimum( + settings.ingestion_min_request_delay_seconds + ), + ) + paging = _resolve_paging_kwargs( + request_delay_seconds=effective_delay, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + rate_limit_retries=rate_limit_retries, + rate_limit_backoff_seconds=rate_limit_backoff_seconds, + max_pages_per_scholar=max_pages_per_scholar, + page_size=page_size, + ) + thresholds = _threshold_kwargs( + alert_blocked_failure_threshold=alert_blocked_failure_threshold, + alert_network_failure_threshold=alert_network_failure_threshold, + alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, + ) + _, run, scholars, start_cstart_map = await self._initialize_run_for_user( + db_session, + user_id=user_id, + trigger_type=trigger_type, + scholar_profile_ids=scholar_profile_ids, + start_cstart_by_scholar_id=start_cstart_by_scholar_id, + idempotency_key=idempotency_key, + paging_kwargs=paging, + threshold_kwargs=thresholds, + ) + return run, scholars, start_cstart_map + + async def execute_run( + self, + session_factory: Any, + *, + run_id: int, + user_id: int, + scholars: list[ScholarProfile], + start_cstart_map: dict[int, int], + request_delay_seconds: int, + network_error_retries: int = 1, + retry_backoff_seconds: float = 1.0, + rate_limit_retries: int | None = None, + rate_limit_backoff_seconds: float | None = None, + max_pages_per_scholar: int = 30, + page_size: int = 100, + auto_queue_continuations: bool = True, + queue_delay_seconds: int = 60, + alert_blocked_failure_threshold: int = 1, + alert_network_failure_threshold: int = 2, + alert_retry_scheduled_threshold: int = 3, + idempotency_key: str | None = None, + ) -> None: + paging = _resolve_paging_kwargs( + request_delay_seconds=request_delay_seconds, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + rate_limit_retries=rate_limit_retries, + rate_limit_backoff_seconds=rate_limit_backoff_seconds, + max_pages_per_scholar=max_pages_per_scholar, + page_size=page_size, + ) + thresholds = _threshold_kwargs( + alert_blocked_failure_threshold=alert_blocked_failure_threshold, + alert_network_failure_threshold=alert_network_failure_threshold, + alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, + ) + async with session_factory() as db_session: + try: + run, user_settings, attached_scholars = await self._prepare_execute_run( + db_session, run_id=run_id, user_id=user_id, scholars=scholars + ) + progress = await run_scholar_iteration( + db_session, + pagination=self._pagination, + run=run, + scholars=attached_scholars, + user_id=user_id, + start_cstart_map=start_cstart_map, + auto_queue_continuations=auto_queue_continuations, + queue_delay_seconds=queue_delay_seconds, + **paging, + ) + failure_summary, alert_summary = complete_run_for_user( + user_settings=user_settings, + run=run, + scholars=attached_scholars, + user_id=user_id, + progress=progress, + idempotency_key=idempotency_key, + **thresholds, + ) + intended_final_status = run.status + if intended_final_status not in (RunStatus.CANCELED,): + run.status = RunStatus.RESOLVING + await db_session.commit() + log_run_completed( + run=run, + user_id=user_id, + scholars=attached_scholars, + progress=progress, + failure_summary=failure_summary, + alert_summary=alert_summary, + ) + if intended_final_status not in (RunStatus.CANCELED,): + spawn_background_enrichment_task( + session_factory, + self._enrichment, + run_id=run.id, + intended_final_status=intended_final_status, + openalex_api_key=getattr(user_settings, "openalex_api_key", None), + ) + except Exception as exc: + await db_session.rollback() + structured_log( + logger, + "exception", + "ingestion.background_run_failed", + run_id=run_id, + user_id=user_id, + ) + await self._fail_run_in_background(session_factory, run_id, exc) + + async def _prepare_execute_run( + self, + db_session: AsyncSession, + *, + run_id: int, + user_id: int, + scholars: list[ScholarProfile], + ) -> tuple[CrawlRun, Any, list[ScholarProfile]]: + run_result = await db_session.execute(select(CrawlRun).where(CrawlRun.id == run_id)) + run = run_result.scalar_one() + user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=user_id) + scholar_ids = [s.id for s in scholars] + scholars_result = await db_session.execute( + select(ScholarProfile) + .where(ScholarProfile.id.in_(scholar_ids)) + .order_by(ScholarProfile.created_at.asc(), ScholarProfile.id.asc()) + ) + return run, user_settings, list(scholars_result.scalars().all()) + + async def _fail_run_in_background(self, session_factory: Any, run_id: int, exc: Exception) -> None: + try: + async with session_factory() as cleanup_session: + run_to_fail = await cleanup_session.get(CrawlRun, run_id) + if run_to_fail: + run_to_fail.status = RunStatus.FAILED + run_to_fail.end_dt = datetime.now(UTC) + run_to_fail.error_log = run_to_fail.error_log or {} + run_to_fail.error_log["terminal_exception"] = str(exc) + await cleanup_session.commit() + except Exception: + structured_log( + logger, + "exception", + "ingestion.fail_run_cleanup_failed", + run_id=run_id, + ) + + async def run_for_user( + self, + db_session: AsyncSession, + *, + user_id: int, + trigger_type: RunTriggerType, + request_delay_seconds: int, + network_error_retries: int = 1, + retry_backoff_seconds: float = 1.0, + rate_limit_retries: int | None = None, + rate_limit_backoff_seconds: float | None = None, + max_pages_per_scholar: int = 30, + page_size: int = 100, + scholar_profile_ids: set[int] | None = None, + start_cstart_by_scholar_id: dict[int, int] | None = None, + auto_queue_continuations: bool = True, + queue_delay_seconds: int = 60, + idempotency_key: str | None = None, + alert_blocked_failure_threshold: int = 1, + alert_network_failure_threshold: int = 2, + alert_retry_scheduled_threshold: int = 3, + ): + run, scholars, start_cstart_map = await self.initialize_run( + db_session, + user_id=user_id, + trigger_type=trigger_type, + request_delay_seconds=request_delay_seconds, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + rate_limit_retries=rate_limit_retries, + rate_limit_backoff_seconds=rate_limit_backoff_seconds, + max_pages_per_scholar=max_pages_per_scholar, + page_size=page_size, + scholar_profile_ids=scholar_profile_ids, + start_cstart_by_scholar_id=start_cstart_by_scholar_id, + idempotency_key=idempotency_key, + alert_blocked_failure_threshold=alert_blocked_failure_threshold, + alert_network_failure_threshold=alert_network_failure_threshold, + alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, + ) + paging = _resolve_paging_kwargs( + request_delay_seconds=self._effective_request_delay_seconds(request_delay_seconds), + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + rate_limit_retries=rate_limit_retries, + rate_limit_backoff_seconds=rate_limit_backoff_seconds, + max_pages_per_scholar=max_pages_per_scholar, + page_size=page_size, + ) + thresholds = _threshold_kwargs( + alert_blocked_failure_threshold=alert_blocked_failure_threshold, + alert_network_failure_threshold=alert_network_failure_threshold, + alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, + ) + progress, failure_summary, alert_summary, intended_final_status = await self._run_iteration_and_complete( + db_session, + run=run, + scholars=scholars, + user_id=user_id, + start_cstart_map=start_cstart_map, + paging=paging, + thresholds=thresholds, + auto_queue_continuations=auto_queue_continuations, + queue_delay_seconds=queue_delay_seconds, + idempotency_key=idempotency_key, + ) + user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=user_id) + await inline_enrich_and_finalize( + db_session, + self._enrichment, + run=run, + user_settings=user_settings, + intended_final_status=intended_final_status, + ) + log_run_completed( + run=run, + user_id=user_id, + scholars=scholars, + progress=progress, + failure_summary=failure_summary, + alert_summary=alert_summary, + ) + return run_execution_summary(run=run, scholars=scholars, progress=progress) + + async def _run_iteration_and_complete( + self, + db_session: AsyncSession, + *, + run: CrawlRun, + scholars: list[ScholarProfile], + user_id: int, + start_cstart_map: dict[int, int], + paging: dict[str, Any], + thresholds: dict[str, Any], + auto_queue_continuations: bool, + queue_delay_seconds: int, + idempotency_key: str | None, + ) -> tuple[RunProgress, RunFailureSummary, RunAlertSummary, RunStatus]: + progress = await run_scholar_iteration( + db_session, + pagination=self._pagination, + run=run, + scholars=scholars, + user_id=user_id, + start_cstart_map=start_cstart_map, + auto_queue_continuations=auto_queue_continuations, + queue_delay_seconds=queue_delay_seconds, + **paging, + ) + user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=user_id) + failure_summary, alert_summary = complete_run_for_user( + user_settings=user_settings, + run=run, + scholars=scholars, + user_id=user_id, + progress=progress, + idempotency_key=idempotency_key, + **thresholds, + ) + intended_final_status = run.status + if intended_final_status not in (RunStatus.CANCELED,): + run.status = RunStatus.RESOLVING + await db_session.commit() + return progress, failure_summary, alert_summary, intended_final_status + + async def _try_acquire_user_lock( + self, + db_session: AsyncSession, + *, + user_id: int, + ) -> bool: + result = await db_session.execute( + text("SELECT pg_try_advisory_xact_lock(:namespace, :user_key)"), + {"namespace": RUN_LOCK_NAMESPACE, "user_key": int(user_id)}, + ) + return bool(result.scalar_one()) diff --git a/app/services/domains/ingestion/constants.py b/app/services/ingestion/constants.py similarity index 93% rename from app/services/domains/ingestion/constants.py rename to app/services/ingestion/constants.py index cfe2ba0..cf371c6 100644 --- a/app/services/domains/ingestion/constants.py +++ b/app/services/ingestion/constants.py @@ -2,7 +2,7 @@ from __future__ import annotations import re -from app.services.domains.scholar.parser import ParseState +from app.services.scholar.parser import ParseState TITLE_ALNUM_RE = re.compile(r"[^a-z0-9]+") WORD_RE = re.compile(r"[a-z0-9]+") diff --git a/app/services/ingestion/enrichment.py b/app/services/ingestion/enrichment.py new file mode 100644 index 0000000..1b66f58 --- /dev/null +++ b/app/services/ingestion/enrichment.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +import asyncio +import logging +import re +from datetime import UTC, datetime, timedelta + +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ( + CrawlRun, + Publication, + RunStatus, + ScholarProfile, + ScholarPublication, +) +from app.logging_utils import structured_log +from app.services.arxiv.errors import ArxivRateLimitError +from app.services.publication_identifiers import application as identifier_service +from app.services.runs.events import run_events +from app.services.scholar.parser import PublicationCandidate +from app.settings import settings + +logger = logging.getLogger(__name__) + + +def _sanitize_titles(publications: list) -> list[str]: + titles = [] + for p in publications: + raw = getattr(p, "title_raw", None) or getattr(p, "title", None) + if not raw or not raw.strip(): + continue + safe = " ".join(re.sub(r"[^\w\s]", " ", raw).split()) + if safe: + titles.append(safe) + return titles + + +class EnrichmentRunner: + """Post-run OpenAlex enrichment logic. + + Receives service dependencies at construction so it can be tested + independently of ``ScholarIngestionService``. + """ + + async def run_is_canceled( + self, + db_session: AsyncSession, + *, + run_id: int, + ) -> bool: + check_result = await db_session.execute(select(CrawlRun.status).where(CrawlRun.id == run_id)) + status = check_result.scalar_one_or_none() + if status is None: + raise RuntimeError(f"Missing crawl_run for run_id={run_id}.") + return status == RunStatus.CANCELED + + async def _load_unenriched_publications( + self, + db_session: AsyncSession, + *, + run_id: int, + ) -> tuple[int, list[Publication]]: + run_result = await db_session.execute(select(CrawlRun.user_id).where(CrawlRun.id == run_id)) + user_id = run_result.scalar_one() + cooldown_threshold = datetime.now(UTC) - timedelta(days=7) + stmt = ( + select(Publication) + .join(ScholarPublication) + .join(ScholarProfile, ScholarPublication.scholar_profile_id == ScholarProfile.id) + .where( + ScholarProfile.user_id == user_id, + Publication.openalex_enriched.is_(False), + or_( + Publication.openalex_last_attempt_at.is_(None), + Publication.openalex_last_attempt_at < cooldown_threshold, + ), + ) + .distinct() + ) + result = await db_session.execute(stmt) + return user_id, list(result.scalars().all()) + + async def _enrich_batch( + self, + db_session: AsyncSession, + *, + batch: list[Publication], + run_id: int, + openalex_works: list, + now: datetime, + arxiv_lookup_allowed: bool, + ) -> tuple[bool, bool]: + from app.services.openalex.matching import find_best_match + + for p in batch: + if await self.run_is_canceled(db_session, run_id=run_id): + structured_log(logger, "info", "ingestion.enrichment_aborted", run_id=run_id) + return False, arxiv_lookup_allowed + p.openalex_last_attempt_at = now + arxiv_lookup_allowed = await self._discover_identifiers_for_enrichment( + db_session, + publication=p, + run_id=run_id, + allow_arxiv_lookup=arxiv_lookup_allowed, + ) + match = find_best_match( + target_title=p.title_raw, + target_year=p.year, + target_authors=p.author_text or "", + candidates=openalex_works, + ) + if match: + p.year = match.publication_year if match.publication_year is not None else p.year + p.citation_count = match.cited_by_count if match.cited_by_count is not None else p.citation_count + p.pdf_url = match.oa_url if match.oa_url is not None else p.pdf_url + p.openalex_enriched = True + return True, arxiv_lookup_allowed + + async def _flush_and_sweep_duplicates( + self, + db_session: AsyncSession, + *, + run_id: int, + ) -> None: + await db_session.flush() + from app.services.publications.dedup import sweep_identifier_duplicates + + merge_count = await sweep_identifier_duplicates(db_session) + if merge_count: + structured_log( + logger, + "info", + "ingestion.identifier_dedup_sweep", + merged_count=merge_count, + run_id=run_id, + ) + + async def enrich_pending_publications( + self, + db_session: AsyncSession, + *, + run_id: int, + openalex_api_key: str | None = None, + ) -> None: + from app.services.openalex.client import ( + OpenAlexBudgetExhaustedError, + OpenAlexClient, + OpenAlexRateLimitError, + ) + + _, publications = await self._load_unenriched_publications(db_session, run_id=run_id) + if not publications: + return + + resolved_key = openalex_api_key or settings.openalex_api_key + client = OpenAlexClient(api_key=resolved_key, mailto=settings.crossref_api_mailto) + batch_size = 25 + now = datetime.now(UTC) + arxiv_lookup_allowed = True + + for i in range(0, len(publications), batch_size): + if await self.run_is_canceled(db_session, run_id=run_id): + structured_log(logger, "info", "ingestion.enrichment_aborted", run_id=run_id) + return + batch = publications[i : i + batch_size] + titles = _sanitize_titles(batch) + if not titles: + continue + try: + openalex_works = await client.get_works_by_filter( + {"title.search": "|".join(titles)}, limit=batch_size * 3 + ) + except OpenAlexBudgetExhaustedError: + structured_log(logger, "warning", "ingestion.openalex_budget_exhausted", run_id=run_id) + break + except OpenAlexRateLimitError: + structured_log(logger, "warning", "ingestion.openalex_rate_limited", run_id=run_id) + await asyncio.sleep(60) + continue + except Exception as e: + structured_log(logger, "warning", "ingestion.openalex_enrichment_failed", error=str(e), run_id=run_id) + for p in batch: + p.openalex_last_attempt_at = now + continue + should_continue, arxiv_lookup_allowed = await self._enrich_batch( + db_session, + batch=batch, + run_id=run_id, + openalex_works=openalex_works, + now=now, + arxiv_lookup_allowed=arxiv_lookup_allowed, + ) + if not should_continue: + return + + await self._flush_and_sweep_duplicates(db_session, run_id=run_id) + + async def _discover_identifiers_for_enrichment( + self, + db_session: AsyncSession, + *, + publication: Publication, + run_id: int, + allow_arxiv_lookup: bool, + ) -> bool: + if not allow_arxiv_lookup: + await identifier_service.sync_identifiers_for_publication_fields( + db_session, + publication=publication, + ) + await self._publish_identifier_update_event( + db_session, + run_id=run_id, + publication_id=int(publication.id), + ) + return False + try: + await identifier_service.discover_and_sync_identifiers_for_publication( + db_session, + publication=publication, + scholar_label=publication.author_text or "", + ) + await self._publish_identifier_update_event( + db_session, + run_id=run_id, + publication_id=int(publication.id), + ) + return True + except ArxivRateLimitError: + structured_log( + logger, + "warning", + "ingestion.arxiv_rate_limited", + run_id=run_id, + publication_id=int(publication.id), + detail="arXiv temporarily disabled for remaining enrichment pass", + ) + await identifier_service.sync_identifiers_for_publication_fields( + db_session, + publication=publication, + ) + await self._publish_identifier_update_event( + db_session, + run_id=run_id, + publication_id=int(publication.id), + ) + return False + + async def _publish_identifier_update_event( + self, + db_session: AsyncSession, + *, + run_id: int, + publication_id: int, + ) -> None: + display = await identifier_service.display_identifier_for_publication_id( + db_session, + publication_id=publication_id, + ) + if display is None: + return + await run_events.publish( + run_id=run_id, + event_type="identifier_updated", + data={ + "publication_id": int(publication_id), + "display_identifier": { + "kind": display.kind, + "value": display.value, + "label": display.label, + "url": display.url, + "confidence_score": float(display.confidence_score), + }, + }, + ) + + async def enrich_publications_with_openalex( + self, + scholar: ScholarProfile, + publications: list[PublicationCandidate], + ) -> list[PublicationCandidate]: + if not publications: + return publications + + from app.services.openalex.client import OpenAlexClient + from app.services.openalex.matching import find_best_match + + client = OpenAlexClient(api_key=settings.openalex_api_key, mailto=settings.crossref_api_mailto) + + batch_size = 25 + enriched: list[PublicationCandidate] = [] + + for i in range(0, len(publications), batch_size): + batch = publications[i : i + batch_size] + titles = _sanitize_titles(batch) + if not titles: + enriched.extend(batch) + continue + + try: + openalex_works = await client.get_works_by_filter( + {"title.search": "|".join(titles)}, limit=batch_size * 3 + ) + except Exception as e: + structured_log( + logger, + "warning", + "ingestion.openalex_enrichment_failed", + error=str(e), + batch_size=len(batch), + scholar_id=scholar.id, + ) + openalex_works = [] + + for p in batch: + match = find_best_match( + target_title=p.title, + target_year=p.year, + target_authors=p.authors_text or (scholar.display_name or scholar.scholar_id), + candidates=openalex_works, + ) + if match: + new_p = PublicationCandidate( + title=p.title, + title_url=p.title_url, + cluster_id=p.cluster_id, + year=match.publication_year if match.publication_year is not None else p.year, + citation_count=match.cited_by_count if match.cited_by_count is not None else p.citation_count, + authors_text=p.authors_text, + venue_text=p.venue_text, + pdf_url=match.oa_url if match.oa_url is not None else p.pdf_url, + ) + enriched.append(new_p) + else: + enriched.append(p) + return enriched diff --git a/app/services/ingestion/fingerprints.py b/app/services/ingestion/fingerprints.py new file mode 100644 index 0000000..0d04f01 --- /dev/null +++ b/app/services/ingestion/fingerprints.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +import hashlib +import json +import re +import unicodedata +from typing import Any +from urllib.parse import urljoin + +from app.services.ingestion.constants import ( + HTML_TAG_RE, + INITIAL_PAGE_FINGERPRINT_MAX_PUBLICATIONS, + SPACE_RE, + TITLE_ALNUM_RE, + WORD_RE, +) +from app.services.scholar.parser import ParsedProfilePage, ParseState, PublicationCandidate + +# Scholar-specific noise patterns stripped before canonical comparison. +# Applied in order; each targets a different Scholar metadata injection style. +_NOISE_DOI_RE = re.compile(r"[,.\s]+doi\s*:\s*\S+.*$", re.IGNORECASE) +_NOISE_ARXIV_RE = re.compile(r"[,.\s]+arxiv\b.*$", re.IGNORECASE) +_NOISE_PREPRINT_RE = re.compile( + r"[,\s]+(?:preprint|extended\s+version|technical\s+report|working\s+paper)\b.*$", + re.IGNORECASE, +) +_NOISE_TRAILING_YEAR_RE = re.compile(r"\s*[,(]\s*\d{4}\s*[),]?\s*$") +_NOISE_TRAILING_MONTH_YEAR_RE = re.compile( + r"\s*[,(]\s*(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+\d{4}\s*[),]?\s*$", + re.IGNORECASE, +) +_NOISE_TRAILING_PUBLICATION_TYPE_RE = re.compile( + r"[,.\s]+(?:conference\s+paper|journal\s+article)\s*$", + re.IGNORECASE, +) +_NOISE_IN_PROCEEDINGS_SUFFIX_RE = re.compile(r"\s+in:\s+proceedings\b.*$", re.IGNORECASE) +# Strips ". Capitalised sentence" appended as venue: ". Comput. Sci…", ". Journal of…" +_NOISE_VENUE_SENTENCE_RE = re.compile(r"(?<=\w{3})\.\s+[A-Z][a-z].*$") +_MOJIBAKE_HINT_RE = re.compile(r"[ÃÂâ]") +_MOJIBAKE_CHAR_RE = re.compile(r"[Ó”€™]") +_METADATA_ORDINAL_RE = re.compile(r"^\d+(st|nd|rd|th)$") +_NOISE_LEADING_DATE_PREFIX_RE = re.compile( + r"^(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\s+\d{1,2}(?:\s*[-–]\s*\d{1,2})?\)?[,\.\s:;-]+", + re.IGNORECASE, +) +_NOISE_LEADING_AUTHOR_FRAGMENT_RE = re.compile(r"^(?:and|&)\s+[a-z.\s]{1,40}:\s*", re.IGNORECASE) +_METADATA_SEPARATORS = (" - ", " — ", ",", ";", ". ") +_VENUE_HINT_TOKENS = { + "aaai", + "conference", + "conf", + "cvpr", + "eccv", + "iclr", + "icml", + "journal", + "nips", + "neurips", + "proceedings", + "proc", + "symposium", + "workshop", +} +_PUBLICATION_TYPE_TOKENS = {"conference", "paper", "journal", "article"} +_MIN_METADATA_HINT_TOKENS = 2 +_MIN_METADATA_CONTEXT_TOKENS = 4 + +_CANONICAL_DEDUP_THRESHOLD = 0.82 + + +def normalize_title(value: str) -> str: + lowered = _normalized_text(value).lower() + return TITLE_ALNUM_RE.sub("", lowered) + + +def canonical_title_for_dedup(title: str) -> str: + """Strip Scholar-specific noise suffixes then normalize for dedup comparison.""" + return normalize_title(_canonical_title_text(title)) + + +def canonical_title_text_for_dedup(title: str) -> str: + """Noise-stripped lowercase title with spaces preserved for token-level matching.""" + return _stripped_title_for_canonical(title) + + +def canonical_title_tokens_for_dedup(title: str) -> set[str]: + """Word tokens of the noise-stripped title.""" + return _canonical_title_tokens(title) + + +def _stripped_title_for_canonical(title: str) -> str: + """Apply noise-stripping and lowercase but PRESERVE spaces (for later tokenization).""" + t = _canonical_title_text(title) + return t.lower().strip() + + +def _canonical_title_text(title: str) -> str: + t = _normalized_text(title) + t = _strip_noise_suffixes(t) + t = _strip_venue_metadata_suffixes(t) + return _NOISE_VENUE_SENTENCE_RE.sub("", t).strip() + + +def _strip_noise_suffixes(value: str) -> str: + t = _strip_leading_noise_prefixes(value.strip()) + t = _NOISE_DOI_RE.sub("", t) + t = _NOISE_ARXIV_RE.sub("", t) + t = _NOISE_PREPRINT_RE.sub("", t) + t = _NOISE_TRAILING_YEAR_RE.sub("", t) + t = _NOISE_TRAILING_MONTH_YEAR_RE.sub("", t) + t = _NOISE_TRAILING_PUBLICATION_TYPE_RE.sub("", t) + t = _NOISE_IN_PROCEEDINGS_SUFFIX_RE.sub("", t) + return t.strip() + + +def _strip_venue_metadata_suffixes(value: str) -> str: + stripped = value.strip() + while True: + cut_index = _metadata_cut_index(stripped) + if cut_index is None: + return stripped + stripped = stripped[:cut_index].strip() + + +def _metadata_cut_index(value: str) -> int | None: + candidates: list[int] = [] + for candidate in _METADATA_SEPARATORS: + start = 0 + while True: + index = value.find(candidate, start) + if index <= 0: + break + suffix = value[index + len(candidate) :].strip() + if suffix and _looks_like_venue_metadata(suffix): + candidates.append(index) + start = index + len(candidate) + if not candidates: + return None + return min(candidates) + + +def _looks_like_venue_metadata(value: str) -> bool: + tokens = WORD_RE.findall(value.lower()) + if len(tokens) < _MIN_METADATA_HINT_TOKENS: + return False + has_hint = any(_is_venue_hint_token(token) for token in tokens) + if not has_hint: + return False + has_year = any(_is_year_token(token) for token in tokens) + has_ordinal = any(_METADATA_ORDINAL_RE.match(token) for token in tokens) + publication_type_only = all(token in _PUBLICATION_TYPE_TOKENS for token in tokens) + return has_year or has_ordinal or publication_type_only or len(tokens) >= _MIN_METADATA_CONTEXT_TOKENS + + +def _strip_leading_noise_prefixes(value: str) -> str: + stripped = value + while True: + next_value = _NOISE_LEADING_DATE_PREFIX_RE.sub("", stripped).strip() + next_value = _NOISE_LEADING_AUTHOR_FRAGMENT_RE.sub("", next_value).strip() + if next_value == stripped: + return stripped + stripped = next_value + + +def _is_venue_hint_token(token: str) -> bool: + if token in _VENUE_HINT_TOKENS: + return True + return token.startswith("conf") or token.startswith("proceed") + + +def _is_year_token(token: str) -> bool: + if len(token) != 4 or not token.isdigit(): + return False + year = int(token) + return 1900 <= year <= 2100 + + +def _normalized_text(value: str) -> str: + repaired = _repair_mojibake(value.strip()) + normalized = unicodedata.normalize("NFKC", repaired) + cleaned = _MOJIBAKE_CHAR_RE.sub(" ", normalized) + return SPACE_RE.sub(" ", cleaned).strip() + + +def _repair_mojibake(value: str) -> str: + if not value or not _MOJIBAKE_HINT_RE.search(value): + return value + try: + repaired = value.encode("latin1").decode("utf-8") + except UnicodeError: + return value + if _mojibake_score(repaired) < _mojibake_score(value): + return repaired + return value + + +def _mojibake_score(value: str) -> int: + return len(_MOJIBAKE_HINT_RE.findall(value)) + + +def _canonical_title_tokens(title: str) -> set[str]: + """Word tokens of the noise-stripped title (preserves token boundaries).""" + return set(WORD_RE.findall(_stripped_title_for_canonical(title))) + + +def _jaccard(a: set[str], b: set[str]) -> float: + if not a or not b: + return 0.0 + return len(a & b) / len(a | b) + + +def _first_author_last_name(authors_text: str | None) -> str: + if not authors_text: + return "" + first_author = authors_text.split(",", maxsplit=1)[0].strip().lower() + words = WORD_RE.findall(first_author) + if not words: + return "" + return words[-1] + + +def _first_venue_word(venue_text: str | None) -> str: + if not venue_text: + return "" + words = WORD_RE.findall(venue_text.lower()) + if not words: + return "" + return words[0] + + +def build_publication_fingerprint(candidate: PublicationCandidate) -> str: + canonical = "|".join( + [ + normalize_title(candidate.title), + str(candidate.year) if candidate.year is not None else "", + _first_author_last_name(candidate.authors_text), + _first_venue_word(candidate.venue_text), + ] + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def build_initial_page_fingerprint(parsed_page: ParsedProfilePage) -> str | None: + if parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: + return None + + normalized_rows: list[dict[str, Any]] = [] + for publication in parsed_page.publications[:INITIAL_PAGE_FINGERPRINT_MAX_PUBLICATIONS]: + normalized_rows.append( + { + "cluster_id": publication.cluster_id or "", + "title_normalized": normalize_title(publication.title), + "year": publication.year, + "citation_count": publication.citation_count, + } + ) + + payload = { + "state": parsed_page.state.value, + "articles_range": parsed_page.articles_range or "", + "has_show_more_button": parsed_page.has_show_more_button, + "profile_name": parsed_page.profile_name or "", + "publications": normalized_rows, + } + canonical = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def build_publication_url(path_or_url: str | None) -> str | None: + if not path_or_url: + return None + return urljoin("https://scholar.google.com", path_or_url) + + +def _next_cstart_value(*, articles_range: str | None, fallback: int) -> int: + if articles_range: + numbers = re.findall(r"\d+", articles_range) + if len(numbers) >= 2: + try: + return int(numbers[1]) + except ValueError: + pass + return int(fallback) + + +def _title_tokens(value: str) -> set[str]: + """Extract normalized word tokens for fuzzy title comparison.""" + return set(WORD_RE.findall(value.lower())) + + +def fuzzy_titles_match( + title_a: str, + title_b: str, + *, + threshold: float = 0.85, +) -> bool: + """Return True if two titles are near-duplicates by token-level Jaccard similarity. + + A threshold of 0.85 catches common academic duplicate patterns: + differences in punctuation, minor word variations, subtitle changes. + """ + tokens_a = _title_tokens(title_a) + tokens_b = _title_tokens(title_b) + return _jaccard(tokens_a, tokens_b) >= threshold + + +def _dedupe_publication_candidates( + publications: list[PublicationCandidate], + *, + seen_canonical: set[str] | None = None, +) -> list[PublicationCandidate]: + """Deduplicate candidates using canonical title matching. + + Args: + publications: candidates to filter + seen_canonical: optional mutable set shared across pages. Stores the + noise-stripped *lowercased* (but space-preserved) canonical string + so it can be tokenized on the next page for cross-page fuzzy dedup. + Accepted canonicals are added; existing entries are consulted. + """ + deduped: list[PublicationCandidate] = [] + seen_exact: set[str] = set() + # Token sets for fuzzy comparison; seeded from cross-page state. + seen_tokens: list[set[str]] = [] + + if seen_canonical: + for stripped in seen_canonical: + seen_tokens.append(set(WORD_RE.findall(stripped))) + + for pub in publications: + identity = _publication_identity(pub) + if identity in seen_exact: + continue + + # Use space-preserving stripped form for token-level fuzzy match. + tokens = _canonical_title_tokens(pub.title) + if _is_fuzzy_dup(tokens, seen_tokens): + continue + + seen_exact.add(identity) + seen_tokens.append(tokens) + if seen_canonical is not None: + # Store the noise-stripped lowercased (space-preserved) form. + seen_canonical.add(_stripped_title_for_canonical(pub.title)) + deduped.append(pub) + + return deduped + + +def _publication_identity(pub: PublicationCandidate) -> str: + if pub.cluster_id: + return f"cluster:{pub.cluster_id}" + canonical = canonical_title_for_dedup(pub.title) + return "|".join( + [ + "fallback", + canonical, + str(pub.year) if pub.year is not None else "", + _first_author_last_name(pub.authors_text), + ] + ) + + +def _is_fuzzy_dup(tokens: set[str], seen: list[set[str]]) -> bool: + return any(_jaccard(tokens, existing) >= _CANONICAL_DEDUP_THRESHOLD for existing in seen) + + +def _build_body_excerpt(body: str, *, max_chars: int = 220) -> str | None: + if not body: + return None + flattened = SPACE_RE.sub(" ", HTML_TAG_RE.sub(" ", body)).strip() + if not flattened: + return None + if len(flattened) <= max_chars: + return flattened + return f"{flattened[: max_chars - 1]}..." diff --git a/app/services/ingestion/page_fetch.py b/app/services/ingestion/page_fetch.py new file mode 100644 index 0000000..3a57206 --- /dev/null +++ b/app/services/ingestion/page_fetch.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from app.logging_utils import structured_log +from app.services.scholar.parser import ( + ParsedProfilePage, + ParseState, + ScholarParserError, + parse_profile_page, +) +from app.services.scholar.source import FetchResult, ScholarSource + +logger = logging.getLogger(__name__) + + +class PageFetcher: + """Fetches and parses a single Google Scholar profile page with retry logic.""" + + def __init__(self, *, source: ScholarSource) -> None: + self._source = source + + async def fetch_profile_page( + self, + *, + scholar_id: str, + cstart: int, + page_size: int, + ) -> FetchResult: + try: + page_fetcher = getattr(self._source, "fetch_profile_page_html", None) + if callable(page_fetcher): + return await page_fetcher( + scholar_id, + cstart=cstart, + pagesize=page_size, + ) + if cstart <= 0: + return await self._source.fetch_profile_html(scholar_id) + return FetchResult( + requested_url=( + f"https://scholar.google.com/citations?hl=en&user={scholar_id}&cstart={cstart}&pagesize={page_size}" + ), + status_code=None, + final_url=None, + body="", + error="source_does_not_support_pagination", + ) + except Exception as exc: + structured_log( + logger, + "exception", + "ingestion.fetch_unexpected_error", + scholar_id=scholar_id, + cstart=cstart, + page_size=page_size, + ) + return FetchResult( + requested_url=( + f"https://scholar.google.com/citations?hl=en&user={scholar_id}&cstart={cstart}&pagesize={page_size}" + ), + status_code=None, + final_url=None, + body="", + error=str(exc), + ) + + # ── Parse helpers ──────────────────────────────────────────────── + + def parse_page_or_layout_error( + self, + *, + fetch_result: FetchResult, + ) -> ParsedProfilePage: + try: + return parse_profile_page(fetch_result) + except ScholarParserError as exc: + return self._parsed_page_from_parser_error(code=exc.code) + + @staticmethod + def _parsed_page_from_parser_error(*, code: str) -> ParsedProfilePage: + return ParsedProfilePage( + state=ParseState.LAYOUT_CHANGED, + state_reason=code, + profile_name=None, + profile_image_url=None, + publications=[], + marker_counts={}, + warnings=[code], + has_show_more_button=False, + has_operation_error_banner=False, + articles_range=None, + ) + + # ── Retry logic ────────────────────────────────────────────────── + + @staticmethod + def _should_retry( + *, + parsed_page: ParsedProfilePage, + network_attempt_count: int, + rate_limit_attempt_count: int, + network_error_retries: int, + rate_limit_retries: int, + ) -> bool: + if parsed_page.state == ParseState.NETWORK_ERROR: + return network_attempt_count <= network_error_retries + if ( + parsed_page.state == ParseState.BLOCKED_OR_CAPTCHA + and parsed_page.state_reason == "blocked_http_429_rate_limited" + ): + return rate_limit_attempt_count <= rate_limit_retries + return False + + @staticmethod + async def _sleep_backoff( + *, + scholar_id: str, + cstart: int, + network_attempt_count: int, + rate_limit_attempt_count: int, + retry_backoff_seconds: float, + rate_limit_backoff_seconds: float, + state_reason: str, + ) -> None: + if state_reason == "blocked_http_429_rate_limited": + sleep_seconds = rate_limit_backoff_seconds * rate_limit_attempt_count + attempt_label = rate_limit_attempt_count + else: + sleep_seconds = retry_backoff_seconds * (2 ** (network_attempt_count - 1)) + attempt_label = network_attempt_count + + structured_log( + logger, + "warning", + "ingestion.scholar_retry_scheduled", + scholar_id=scholar_id, + cstart=cstart, + attempt_count=attempt_label, + sleep_seconds=sleep_seconds, + state_reason=state_reason, + ) + if sleep_seconds > 0: + await asyncio.sleep(sleep_seconds) + + @staticmethod + def _classify_attempt( + parsed_page: ParsedProfilePage, + *, + network_attempts: int, + rate_limit_attempts: int, + ) -> tuple[int, int, int]: + if parsed_page.state == ParseState.NETWORK_ERROR: + network_attempts += 1 + return network_attempts, rate_limit_attempts, network_attempts + if ( + parsed_page.state == ParseState.BLOCKED_OR_CAPTCHA + and parsed_page.state_reason == "blocked_http_429_rate_limited" + ): + rate_limit_attempts += 1 + return network_attempts, rate_limit_attempts, rate_limit_attempts + return network_attempts, rate_limit_attempts, network_attempts + rate_limit_attempts + 1 + + async def fetch_and_parse_with_retry( + self, + *, + scholar_id: str, + cstart: int, + page_size: int, + network_error_retries: int, + retry_backoff_seconds: float, + rate_limit_retries: int, + rate_limit_backoff_seconds: float, + ) -> tuple[FetchResult, ParsedProfilePage, list[dict[str, Any]]]: + network_attempts = 0 + rate_limit_attempts = 0 + attempt_log: list[dict[str, Any]] = [] + fetch_result: FetchResult | None = None + parsed_page: ParsedProfilePage | None = None + + while True: + fetch_result = await self.fetch_profile_page( + scholar_id=scholar_id, + cstart=cstart, + page_size=page_size, + ) + parsed_page = self.parse_page_or_layout_error(fetch_result=fetch_result) + network_attempts, rate_limit_attempts, total_attempts = self._classify_attempt( + parsed_page, network_attempts=network_attempts, rate_limit_attempts=rate_limit_attempts + ) + attempt_log.append( + { + "attempt": total_attempts, + "cstart": cstart, + "state": parsed_page.state.value, + "state_reason": parsed_page.state_reason, + "status_code": fetch_result.status_code, + "fetch_error": fetch_result.error, + } + ) + if not self._should_retry( + parsed_page=parsed_page, + network_attempt_count=network_attempts, + rate_limit_attempt_count=rate_limit_attempts, + network_error_retries=network_error_retries, + rate_limit_retries=rate_limit_retries, + ): + break + await self._sleep_backoff( + scholar_id=scholar_id, + cstart=cstart, + network_attempt_count=network_attempts, + rate_limit_attempt_count=rate_limit_attempts, + retry_backoff_seconds=max(float(retry_backoff_seconds), 0.0), + rate_limit_backoff_seconds=max(float(rate_limit_backoff_seconds), 0.0), + state_reason=parsed_page.state_reason, + ) + + if fetch_result is None or parsed_page is None: + raise RuntimeError("Fetch-and-parse retry loop produced no result.") + return fetch_result, parsed_page, attempt_log diff --git a/app/services/ingestion/pagination.py b/app/services/ingestion/pagination.py new file mode 100644 index 0000000..91bc984 --- /dev/null +++ b/app/services/ingestion/pagination.py @@ -0,0 +1,504 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from app.db.models import CrawlRun, RunStatus, ScholarProfile +from app.logging_utils import structured_log +from app.services.ingestion.fingerprints import ( + _dedupe_publication_candidates, + _next_cstart_value, + build_initial_page_fingerprint, +) +from app.services.ingestion.page_fetch import PageFetcher +from app.services.ingestion.types import PagedLoopState, PagedParseResult +from app.services.scholar.parser import ParsedProfilePage, ParseState +from app.services.scholar.source import FetchResult, ScholarSource + +logger = logging.getLogger(__name__) + + +class PaginationEngine: + """Fetches and paginates Google Scholar profile pages. + + Pure HTTP + parsing — no DB writes except via the provided upsert callback. + """ + + def __init__(self, *, source: ScholarSource) -> None: + self._source = source + self._fetcher = PageFetcher(source=source) + + # ── No-change / initial failure short-circuits ─────────────────── + + @staticmethod + def _should_skip_no_change( + *, + start_cstart: int, + first_page_fingerprint_sha256: str | None, + previous_initial_page_fingerprint_sha256: str | None, + parsed_page: ParsedProfilePage, + ) -> bool: + return ( + start_cstart <= 0 + and first_page_fingerprint_sha256 is not None + and previous_initial_page_fingerprint_sha256 is not None + and first_page_fingerprint_sha256 == previous_initial_page_fingerprint_sha256 + and parsed_page.state in {ParseState.OK, ParseState.NO_RESULTS} + ) + + @staticmethod + def _skip_no_change_result( + *, + fetch_result: FetchResult, + parsed_page: ParsedProfilePage, + first_page_fingerprint_sha256: str | None, + attempt_log: list[dict[str, Any]], + page_logs: list[dict[str, Any]], + ) -> PagedParseResult: + return PagedParseResult( + fetch_result=fetch_result, + parsed_page=parsed_page, + first_page_fetch_result=fetch_result, + first_page_parsed_page=parsed_page, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, + publications=[], + attempt_log=attempt_log, + page_logs=page_logs, + pages_fetched=1, + pages_attempted=1, + has_more_remaining=False, + pagination_truncated_reason=None, + continuation_cstart=None, + skipped_no_change=True, + discovered_publication_count=0, + ) + + @staticmethod + def _initial_failure_result( + *, + fetch_result: FetchResult, + parsed_page: ParsedProfilePage, + first_page_fingerprint_sha256: str | None, + start_cstart: int, + attempt_log: list[dict[str, Any]], + page_logs: list[dict[str, Any]], + ) -> PagedParseResult: + continuation_cstart = start_cstart if parsed_page.state == ParseState.NETWORK_ERROR else None + return PagedParseResult( + fetch_result=fetch_result, + parsed_page=parsed_page, + first_page_fetch_result=fetch_result, + first_page_parsed_page=parsed_page, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, + publications=[], + attempt_log=attempt_log, + page_logs=page_logs, + pages_fetched=0, + pages_attempted=1, + has_more_remaining=False, + pagination_truncated_reason=None, + continuation_cstart=continuation_cstart, + skipped_no_change=False, + discovered_publication_count=0, + ) + + # ── Loop state management ──────────────────────────────────────── + + @staticmethod + def _build_loop_state( + *, + start_cstart: int, + fetch_result: FetchResult, + parsed_page: ParsedProfilePage, + attempt_log: list[dict[str, Any]], + page_logs: list[dict[str, Any]], + ) -> PagedLoopState: + next_cstart = _next_cstart_value( + articles_range=parsed_page.articles_range, + fallback=start_cstart + len(parsed_page.publications), + ) + return PagedLoopState( + fetch_result=fetch_result, + parsed_page=parsed_page, + attempt_log=attempt_log, + page_logs=page_logs, + publications=list(parsed_page.publications), + pages_fetched=1, + pages_attempted=1, + current_cstart=start_cstart, + next_cstart=next_cstart, + ) + + @staticmethod + def _set_truncated_state( + *, + state: PagedLoopState, + reason: str, + continuation_cstart: int, + ) -> None: + state.has_more_remaining = True + state.pagination_truncated_reason = reason + state.continuation_cstart = continuation_cstart + + def _should_stop_pagination(self, *, state: PagedLoopState, bounded_max_pages: int) -> bool: + if state.pages_fetched >= bounded_max_pages: + self._set_truncated_state( + state=state, + reason="max_pages_reached", + continuation_cstart=( + state.next_cstart if state.next_cstart > state.current_cstart else state.current_cstart + ), + ) + return True + if state.next_cstart <= state.current_cstart: + self._set_truncated_state( + state=state, + reason="pagination_cursor_stalled", + continuation_cstart=state.current_cstart, + ) + return True + return False + + # ── Multi-page fetch helpers ───────────────────────────────────── + + async def _fetch_next_page( + self, + *, + scholar_id: str, + state: PagedLoopState, + request_delay_seconds: int, + bounded_page_size: int, + network_error_retries: int, + retry_backoff_seconds: float, + rate_limit_retries: int, + rate_limit_backoff_seconds: float, + ) -> tuple[FetchResult, ParsedProfilePage, list[dict[str, Any]]]: + if request_delay_seconds > 0: + await asyncio.sleep(float(request_delay_seconds)) + state.current_cstart = state.next_cstart + return await self._fetcher.fetch_and_parse_with_retry( + scholar_id=scholar_id, + cstart=state.current_cstart, + page_size=bounded_page_size, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + rate_limit_retries=rate_limit_retries, + rate_limit_backoff_seconds=rate_limit_backoff_seconds, + ) + + @staticmethod + def _record_next_page( + *, + state: PagedLoopState, + fetch_result: FetchResult, + parsed_page: ParsedProfilePage, + page_attempt_log: list[dict[str, Any]], + ) -> None: + state.pages_attempted += 1 + state.attempt_log.extend(page_attempt_log) + state.page_logs.append( + { + "page": state.pages_attempted, + "cstart": state.current_cstart, + "state": parsed_page.state.value, + "state_reason": parsed_page.state_reason, + "status_code": fetch_result.status_code, + "publication_count": len(parsed_page.publications), + "articles_range": parsed_page.articles_range, + "has_show_more_button": parsed_page.has_show_more_button, + "warning_codes": parsed_page.warnings, + "attempt_count": len(page_attempt_log), + } + ) + state.fetch_result = fetch_result + state.parsed_page = parsed_page + + def _handle_page_state_transition(self, *, state: PagedLoopState) -> bool: + if state.parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: + self._set_truncated_state( + state=state, + reason=f"page_state_{state.parsed_page.state.value}", + continuation_cstart=state.current_cstart, + ) + return True + if state.parsed_page.state == ParseState.NO_RESULTS and len(state.parsed_page.publications) == 0: + state.pages_fetched += 1 + return True + state.pages_fetched += 1 + state.publications.extend(state.parsed_page.publications) + state.next_cstart = _next_cstart_value( + articles_range=state.parsed_page.articles_range, + fallback=state.current_cstart + len(state.parsed_page.publications), + ) + return False + + async def _fetch_initial_page_context( + self, + *, + scholar_id: str, + start_cstart: int, + bounded_page_size: int, + network_error_retries: int, + retry_backoff_seconds: float, + rate_limit_retries: int, + rate_limit_backoff_seconds: float, + ) -> tuple[FetchResult, ParsedProfilePage, str | None, list[dict[str, Any]], list[dict[str, Any]]]: + fetch_result, parsed_page, first_attempt_log = await self._fetcher.fetch_and_parse_with_retry( + scholar_id=scholar_id, + cstart=start_cstart, + page_size=bounded_page_size, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + rate_limit_retries=rate_limit_retries, + rate_limit_backoff_seconds=rate_limit_backoff_seconds, + ) + first_page_fingerprint_sha256 = build_initial_page_fingerprint(parsed_page) + attempt_log = list(first_attempt_log) + page_logs = [ + { + "page": 1, + "cstart": start_cstart, + "state": parsed_page.state.value, + "state_reason": parsed_page.state_reason, + "status_code": fetch_result.status_code, + "publication_count": len(parsed_page.publications), + "articles_range": parsed_page.articles_range, + "has_show_more_button": parsed_page.has_show_more_button, + "warning_codes": parsed_page.warnings, + "attempt_count": len(first_attempt_log), + } + ] + return fetch_result, parsed_page, first_page_fingerprint_sha256, attempt_log, page_logs + + # ── Pagination loop ────────────────────────────────────────────── + + @staticmethod + async def _upsert_page_publications( + db_session: Any, + *, + run: CrawlRun, + scholar: ScholarProfile, + publications: list, + seen_canonical: set[str], + state: PagedLoopState, + upsert_publications_fn: Any, + ) -> None: + deduped = _dedupe_publication_candidates(list(publications), seen_canonical=seen_canonical) + if deduped: + discovered_count = await upsert_publications_fn(db_session, run=run, scholar=scholar, publications=deduped) + state.discovered_publication_count += discovered_count + + async def _paginate_loop( + self, + *, + scholar: ScholarProfile, + run: CrawlRun, + db_session: Any, + state: PagedLoopState, + bounded_max_pages: int, + request_delay_seconds: int, + bounded_page_size: int, + network_error_retries: int, + retry_backoff_seconds: float, + rate_limit_retries: int, + rate_limit_backoff_seconds: float, + upsert_publications_fn: Any, + ) -> None: + seen_canonical: set[str] = set() + + if state.parsed_page.publications: + await self._upsert_page_publications( + db_session, + run=run, + scholar=scholar, + publications=state.parsed_page.publications, + seen_canonical=seen_canonical, + state=state, + upsert_publications_fn=upsert_publications_fn, + ) + + while state.parsed_page.has_show_more_button: + await db_session.refresh(run) + if run.status == RunStatus.CANCELED: + structured_log( + logger, + "info", + "ingestion.pagination_canceled", + run_id=run.id, + ) + self._set_truncated_state( + state=state, + reason="run_canceled", + continuation_cstart=state.current_cstart, + ) + return + + if self._should_stop_pagination(state=state, bounded_max_pages=bounded_max_pages): + return + next_fetch_result, next_parsed_page, next_attempt_log = await self._fetch_next_page( + scholar_id=scholar.scholar_id, + state=state, + request_delay_seconds=request_delay_seconds, + bounded_page_size=bounded_page_size, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + rate_limit_retries=rate_limit_retries, + rate_limit_backoff_seconds=rate_limit_backoff_seconds, + ) + self._record_next_page( + state=state, + fetch_result=next_fetch_result, + parsed_page=next_parsed_page, + page_attempt_log=next_attempt_log, + ) + + if self._handle_page_state_transition(state=state): + return + + if next_parsed_page.publications: + await self._upsert_page_publications( + db_session, + run=run, + scholar=scholar, + publications=next_parsed_page.publications, + seen_canonical=seen_canonical, + state=state, + upsert_publications_fn=upsert_publications_fn, + ) + + @staticmethod + def _result_from_pagination_state( + *, + state: PagedLoopState, + first_page_fetch_result: FetchResult, + first_page_parsed_page: ParsedProfilePage, + first_page_fingerprint_sha256: str | None, + ) -> PagedParseResult: + return PagedParseResult( + fetch_result=state.fetch_result, + parsed_page=state.parsed_page, + first_page_fetch_result=first_page_fetch_result, + first_page_parsed_page=first_page_parsed_page, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, + publications=_dedupe_publication_candidates(state.publications), + attempt_log=state.attempt_log, + page_logs=state.page_logs, + pages_fetched=state.pages_fetched, + pages_attempted=state.pages_attempted, + has_more_remaining=state.has_more_remaining, + pagination_truncated_reason=state.pagination_truncated_reason, + continuation_cstart=state.continuation_cstart, + skipped_no_change=False, + discovered_publication_count=state.discovered_publication_count, + ) + + def _short_circuit_initial_page( + self, + *, + start_cstart: int, + previous_initial_page_fingerprint_sha256: str | None, + fetch_result: FetchResult, + parsed_page: ParsedProfilePage, + first_page_fingerprint_sha256: str | None, + attempt_log: list[dict[str, Any]], + page_logs: list[dict[str, Any]], + ) -> PagedParseResult | None: + if self._should_skip_no_change( + start_cstart=start_cstart, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, + previous_initial_page_fingerprint_sha256=previous_initial_page_fingerprint_sha256, + parsed_page=parsed_page, + ): + return self._skip_no_change_result( + fetch_result=fetch_result, + parsed_page=parsed_page, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, + attempt_log=attempt_log, + page_logs=page_logs, + ) + if parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS}: + return self._initial_failure_result( + fetch_result=fetch_result, + parsed_page=parsed_page, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, + start_cstart=start_cstart, + attempt_log=attempt_log, + page_logs=page_logs, + ) + return None + + # ── Public entry point ─────────────────────────────────────────── + + async def fetch_and_parse_all_pages( + self, + *, + scholar: ScholarProfile, + run: CrawlRun, + db_session: Any, + start_cstart: int, + request_delay_seconds: int, + network_error_retries: int, + retry_backoff_seconds: float, + rate_limit_retries: int, + rate_limit_backoff_seconds: float, + max_pages: int, + page_size: int, + previous_initial_page_fingerprint_sha256: str | None = None, + upsert_publications_fn: Any = None, + ) -> PagedParseResult: + bounded_max_pages = max(1, int(max_pages)) + bounded_page_size = max(1, int(page_size)) + ( + fetch_result, + parsed_page, + first_page_fingerprint_sha256, + attempt_log, + page_logs, + ) = await self._fetch_initial_page_context( + scholar_id=scholar.scholar_id, + start_cstart=start_cstart, + bounded_page_size=bounded_page_size, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + rate_limit_retries=rate_limit_retries, + rate_limit_backoff_seconds=rate_limit_backoff_seconds, + ) + shortcut_result = self._short_circuit_initial_page( + start_cstart=start_cstart, + previous_initial_page_fingerprint_sha256=previous_initial_page_fingerprint_sha256, + fetch_result=fetch_result, + parsed_page=parsed_page, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, + attempt_log=attempt_log, + page_logs=page_logs, + ) + if shortcut_result is not None: + return shortcut_result + state = self._build_loop_state( + start_cstart=start_cstart, + fetch_result=fetch_result, + parsed_page=parsed_page, + attempt_log=attempt_log, + page_logs=page_logs, + ) + await self._paginate_loop( + scholar=scholar, + run=run, + db_session=db_session, + state=state, + bounded_max_pages=bounded_max_pages, + request_delay_seconds=request_delay_seconds, + bounded_page_size=bounded_page_size, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + rate_limit_retries=rate_limit_retries, + rate_limit_backoff_seconds=rate_limit_backoff_seconds, + upsert_publications_fn=upsert_publications_fn, + ) + return self._result_from_pagination_state( + state=state, + first_page_fetch_result=fetch_result, + first_page_parsed_page=parsed_page, + first_page_fingerprint_sha256=first_page_fingerprint_sha256, + ) diff --git a/app/services/ingestion/preflight.py b/app/services/ingestion/preflight.py new file mode 100644 index 0000000..44441e3 --- /dev/null +++ b/app/services/ingestion/preflight.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from app.logging_utils import structured_log +from app.services.scholar.source import FetchResult, ScholarSource +from app.services.scholar.state_detection import ( + classify_block_or_captcha_reason, + is_hard_challenge_reason, +) + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class PreflightResult: + passed: bool + block_reason: str | None + status_code: int | None + + +def _evaluate_fetch_result(fetch_result: FetchResult) -> PreflightResult: + if fetch_result.status_code is None: + return PreflightResult(passed=True, block_reason=None, status_code=None) + block_reason = classify_block_or_captcha_reason( + status_code=fetch_result.status_code, + final_url=(fetch_result.final_url or "").lower(), + body_lowered=fetch_result.body.lower(), + ) + if block_reason is not None and is_hard_challenge_reason(block_reason): + return PreflightResult( + passed=False, + block_reason=block_reason, + status_code=fetch_result.status_code, + ) + return PreflightResult( + passed=True, + block_reason=None, + status_code=fetch_result.status_code, + ) + + +async def check_scholar_reachable( + source: ScholarSource, + *, + scholar_id: str, +) -> PreflightResult: + """Single-request probe to detect active Scholar blocks before a full run.""" + structured_log(logger, "info", "ingestion.preflight_started", scholar_id=scholar_id) + fetch_result = await source.fetch_profile_html(scholar_id) + result = _evaluate_fetch_result(fetch_result) + if result.passed: + structured_log( + logger, + "info", + "ingestion.preflight_passed", + scholar_id=scholar_id, + status_code=result.status_code, + ) + else: + structured_log( + logger, + "warning", + "ingestion.preflight_failed", + scholar_id=scholar_id, + block_reason=result.block_reason, + status_code=result.status_code, + ) + return result diff --git a/app/services/ingestion/publication_upsert.py b/app/services/ingestion/publication_upsert.py new file mode 100644 index 0000000..ae7b1b8 --- /dev/null +++ b/app/services/ingestion/publication_upsert.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import hashlib +import logging +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import CrawlRun, Publication, ScholarProfile, ScholarPublication +from app.services.ingestion.fingerprints import ( + build_publication_fingerprint, + build_publication_url, + canonical_title_for_dedup, + normalize_title, +) +from app.services.publication_identifiers import application as identifier_service +from app.services.runs.events import run_events +from app.services.scholar.parser import PublicationCandidate + +logger = logging.getLogger(__name__) + + +def validate_publication_candidate(candidate: PublicationCandidate) -> None: + if not candidate.title.strip(): + raise RuntimeError("Publication candidate is missing title.") + if candidate.citation_count is not None and int(candidate.citation_count) < 0: + raise RuntimeError("Publication candidate has negative citation_count.") + + +async def find_publication_by_cluster( + db_session: AsyncSession, + *, + cluster_id: str | None, +) -> Publication | None: + if not cluster_id: + return None + result = await db_session.execute(select(Publication).where(Publication.cluster_id == cluster_id)) + return result.scalar_one_or_none() + + +async def find_publication_by_fingerprint( + db_session: AsyncSession, + *, + fingerprint: str, +) -> Publication | None: + result = await db_session.execute(select(Publication).where(Publication.fingerprint_sha256 == fingerprint)) + return result.scalar_one_or_none() + + +def select_existing_publication( + *, + cluster_publication: Publication | None, + fingerprint_publication: Publication | None, +) -> Publication | None: + if cluster_publication is not None: + return cluster_publication + return fingerprint_publication + + +def compute_canonical_title_hash(title: str) -> str: + canonical = canonical_title_for_dedup(title) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +async def find_publication_by_canonical_title_hash( + db_session: AsyncSession, + *, + canonical_title_hash: str, +) -> Publication | None: + result = await db_session.execute( + select(Publication).where(Publication.canonical_title_hash == canonical_title_hash) + ) + return result.scalar_one_or_none() + + +async def create_publication( + db_session: AsyncSession, + *, + candidate: PublicationCandidate, + fingerprint: str, +) -> Publication: + publication = Publication( + cluster_id=candidate.cluster_id, + fingerprint_sha256=fingerprint, + title_raw=candidate.title, + title_normalized=normalize_title(candidate.title), + canonical_title_hash=compute_canonical_title_hash(candidate.title), + year=candidate.year, + citation_count=int(candidate.citation_count or 0), + author_text=candidate.authors_text, + venue_text=candidate.venue_text, + pub_url=build_publication_url(candidate.title_url), + pdf_url=None, + ) + db_session.add(publication) + await db_session.flush() + return publication + + +def update_existing_publication( + *, + publication: Publication, + candidate: PublicationCandidate, +) -> None: + if candidate.cluster_id and publication.cluster_id is None: + publication.cluster_id = candidate.cluster_id + if not publication.title_raw: + publication.title_raw = candidate.title + publication.title_normalized = normalize_title(candidate.title) + if candidate.year is not None: + publication.year = candidate.year + if candidate.citation_count is not None: + publication.citation_count = int(candidate.citation_count) + if candidate.authors_text: + publication.author_text = candidate.authors_text + if candidate.venue_text: + publication.venue_text = candidate.venue_text + if candidate.title_url: + publication.pub_url = build_publication_url(candidate.title_url) + + +async def resolve_publication( + db_session: AsyncSession, + candidate: PublicationCandidate, +) -> Publication: + validate_publication_candidate(candidate) + fingerprint = build_publication_fingerprint(candidate) + cluster_publication = await find_publication_by_cluster( + db_session, + cluster_id=candidate.cluster_id, + ) + fingerprint_publication = await find_publication_by_fingerprint( + db_session, + fingerprint=fingerprint, + ) + publication = select_existing_publication( + cluster_publication=cluster_publication, + fingerprint_publication=fingerprint_publication, + ) + if publication is None: + canonical_hash = compute_canonical_title_hash(candidate.title) + publication = await find_publication_by_canonical_title_hash( + db_session, + canonical_title_hash=canonical_hash, + ) + if publication is None: + created = await create_publication( + db_session, + candidate=candidate, + fingerprint=fingerprint, + ) + await identifier_service.sync_identifiers_for_publication_fields( + db_session, + publication=created, + ) + return created + update_existing_publication( + publication=publication, + candidate=candidate, + ) + await identifier_service.sync_identifiers_for_publication_fields( + db_session, + publication=publication, + ) + return publication + + +async def upsert_profile_publications( + db_session: AsyncSession, + *, + run: CrawlRun, + scholar: ScholarProfile, + publications: list[PublicationCandidate], +) -> int: + seen_publication_ids: set[int] = set() + discovered_count = 0 + + try: + for candidate in publications: + publication = await resolve_publication(db_session, candidate) + if publication.id in seen_publication_ids: + continue + seen_publication_ids.add(publication.id) + + link_result = await db_session.execute( + select(ScholarPublication).where( + ScholarPublication.scholar_profile_id == scholar.id, + ScholarPublication.publication_id == publication.id, + ) + ) + link = link_result.scalar_one_or_none() + if link is not None: + continue + + link = ScholarPublication( + scholar_profile_id=scholar.id, + publication_id=publication.id, + is_read=False, + first_seen_run_id=run.id, + ) + db_session.add(link) + discovered_count += 1 + + await flush_discovered_publication( + db_session, + run=run, + scholar=scholar, + publication=publication, + ) + + if not scholar.baseline_completed: + scholar.baseline_completed = True + + await db_session.commit() + except Exception: + await db_session.rollback() + raise + + return discovered_count + + +async def flush_discovered_publication( + db_session: AsyncSession, + *, + run: CrawlRun, + scholar: ScholarProfile, + publication: Publication, +) -> None: + run.new_pub_count = int(run.new_pub_count or 0) + 1 + await db_session.flush() + await run_events.publish( + run_id=run.id, + event_type="publication_discovered", + data={ + "publication_id": publication.id, + "title": publication.title_raw, + "pub_url": publication.pub_url, + "scholar_profile_id": scholar.id, + "scholar_label": scholar.display_name or scholar.scholar_id, + "first_seen_at": datetime.now(UTC).isoformat(), + "new_publication_count": int(run.new_pub_count or 0), + }, + ) diff --git a/app/services/domains/ingestion/queue.py b/app/services/ingestion/queue.py similarity index 87% rename from app/services/domains/ingestion/queue.py rename to app/services/ingestion/queue.py index 7494420..8bdddbc 100644 --- a/app/services/domains/ingestion/queue.py +++ b/app/services/ingestion/queue.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -115,7 +115,7 @@ async def upsert_job( run_id: int | None, delay_seconds: int, ) -> IngestionQueueItem: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) next_attempt_dt = now + timedelta(seconds=max(0, int(delay_seconds))) item = await _get_item_for_user_scholar( db_session, @@ -171,9 +171,7 @@ async def delete_job_by_id( *, job_id: int, ) -> bool: - result = await db_session.execute( - select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) - ) + result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) item = result.scalar_one_or_none() if item is None: return False @@ -198,6 +196,7 @@ async def list_due_jobs( IngestionQueueItem.id.asc(), ) .limit(limit) + .with_for_update(skip_locked=True) ) rows = list(result.scalars().all()) jobs: list[ContinuationQueueJob] = [] @@ -222,10 +221,8 @@ async def increment_attempt_count( *, job_id: int, ) -> IngestionQueueItem | None: - now = datetime.now(timezone.utc) - result = await db_session.execute( - select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) - ) + now = datetime.now(UTC) + result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) item = result.scalar_one_or_none() if item is None: return None @@ -239,10 +236,8 @@ async def reset_attempt_count( *, job_id: int, ) -> IngestionQueueItem | None: - now = datetime.now(timezone.utc) - result = await db_session.execute( - select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) - ) + now = datetime.now(UTC) + result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) item = result.scalar_one_or_none() if item is None: return None @@ -259,10 +254,8 @@ async def reschedule_job( reason: str, error: str | None = None, ) -> IngestionQueueItem | None: - now = datetime.now(timezone.utc) - result = await db_session.execute( - select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) - ) + now = datetime.now(UTC) + result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) item = result.scalar_one_or_none() if item is None: return None @@ -282,10 +275,8 @@ async def mark_retrying( job_id: int, reason: str | None = None, ) -> IngestionQueueItem | None: - now = datetime.now(timezone.utc) - result = await db_session.execute( - select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) - ) + now = datetime.now(UTC) + result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) item = result.scalar_one_or_none() if item is None: return None @@ -305,10 +296,8 @@ async def mark_dropped( reason: str, error: str | None = None, ) -> IngestionQueueItem | None: - now = datetime.now(timezone.utc) - result = await db_session.execute( - select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) - ) + now = datetime.now(UTC) + result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) item = result.scalar_one_or_none() if item is None: return None @@ -329,10 +318,8 @@ async def mark_queued_now( reason: str, reset_attempt_count: bool = False, ) -> IngestionQueueItem | None: - now = datetime.now(timezone.utc) - result = await db_session.execute( - select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) - ) + now = datetime.now(UTC) + result = await db_session.execute(select(IngestionQueueItem).where(IngestionQueueItem.id == job_id)) item = result.scalar_one_or_none() if item is None: return None diff --git a/app/services/ingestion/queue_runner.py b/app/services/ingestion/queue_runner.py new file mode 100644 index 0000000..2220f58 --- /dev/null +++ b/app/services/ingestion/queue_runner.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +import logging +from datetime import UTC, datetime + +from sqlalchemy import select + +from app.db.models import QueueItemStatus, RunTriggerType, ScholarProfile, UserSetting +from app.db.session import get_session_factory +from app.logging_utils import structured_log +from app.services.ingestion import queue as queue_service +from app.services.ingestion.application import ( + RunAlreadyInProgressError, + RunBlockedBySafetyPolicyError, + ScholarIngestionService, +) +from app.services.scholar.source import LiveScholarSource +from app.settings import settings + +logger = logging.getLogger(__name__) + + +def effective_request_delay_seconds(value: int | None, *, floor: int) -> int: + try: + parsed = int(value) if value is not None else floor + except (TypeError, ValueError): + parsed = floor + return max(floor, parsed) + + +class QueueJobRunner: + def __init__( + self, + *, + tick_seconds: int, + network_error_retries: int, + retry_backoff_seconds: float, + max_pages_per_scholar: int, + page_size: int, + continuation_queue_enabled: bool, + continuation_base_delay_seconds: int, + continuation_max_delay_seconds: int, + continuation_max_attempts: int, + queue_batch_size: int, + ) -> None: + self._tick_seconds = tick_seconds + self._network_error_retries = network_error_retries + self._retry_backoff_seconds = retry_backoff_seconds + self._max_pages_per_scholar = max_pages_per_scholar + self._page_size = page_size + self._continuation_queue_enabled = continuation_queue_enabled + self._continuation_base_delay_seconds = continuation_base_delay_seconds + self._continuation_max_delay_seconds = continuation_max_delay_seconds + self._continuation_max_attempts = continuation_max_attempts + self._queue_batch_size = queue_batch_size + self._source = LiveScholarSource() + + async def drain_continuation_queue(self) -> None: + now = datetime.now(UTC) + session_factory = get_session_factory() + async with session_factory() as session: + jobs = await queue_service.list_due_jobs( + session, + now=now, + limit=self._queue_batch_size, + ) + for job in jobs: + await self._run_queue_job(job) + + async def _drop_queue_job_if_max_attempts( + self, + job: queue_service.ContinuationQueueJob, + ) -> bool: + if job.attempt_count < self._continuation_max_attempts: + return False + session_factory = get_session_factory() + async with session_factory() as session: + dropped = await queue_service.mark_dropped( + session, + job_id=job.id, + reason="max_attempts_reached", + ) + await session.commit() + if dropped is not None: + structured_log( + logger, + "warning", + "scheduler.queue_item_dropped_max_attempts", + queue_item_id=job.id, + user_id=job.user_id, + scholar_profile_id=job.scholar_profile_id, + attempt_count=job.attempt_count, + max_attempts=self._continuation_max_attempts, + ) + return True + + async def _mark_queue_job_retrying( + self, + job: queue_service.ContinuationQueueJob, + ) -> bool: + session_factory = get_session_factory() + async with session_factory() as session: + queue_item = await queue_service.mark_retrying(session, job_id=job.id) + await session.commit() + if queue_item is None: + return False + return queue_item.status != QueueItemStatus.DROPPED.value + + async def _queue_job_has_available_scholar( + self, + job: queue_service.ContinuationQueueJob, + ) -> bool: + session_factory = get_session_factory() + async with session_factory() as session: + scholar_result = await session.execute( + select(ScholarProfile.id).where( + ScholarProfile.user_id == job.user_id, + ScholarProfile.id == job.scholar_profile_id, + ScholarProfile.is_enabled.is_(True), + ) + ) + scholar_id = scholar_result.scalar_one_or_none() + if scholar_id is not None: + return True + dropped = await queue_service.mark_dropped( + session, + job_id=job.id, + reason="scholar_unavailable", + ) + await session.commit() + if dropped is not None: + structured_log( + logger, + "info", + "scheduler.queue_item_dropped_scholar_unavailable", + queue_item_id=job.id, + user_id=job.user_id, + scholar_profile_id=job.scholar_profile_id, + ) + return False + + async def _reschedule_queue_job_lock_active(self, job: queue_service.ContinuationQueueJob) -> None: + session_factory = get_session_factory() + async with session_factory() as recovery_session: + await queue_service.reschedule_job( + recovery_session, + job_id=job.id, + delay_seconds=max(self._tick_seconds, 15), + reason="user_run_lock_active", + error="run_already_in_progress", + ) + await recovery_session.commit() + structured_log( + logger, + "info", + "scheduler.queue_item_deferred_lock", + queue_item_id=job.id, + user_id=job.user_id, + ) + + async def _reschedule_queue_job_safety_cooldown( + self, + job: queue_service.ContinuationQueueJob, + exc: RunBlockedBySafetyPolicyError, + ) -> None: + cooldown_remaining_seconds = max( + self._tick_seconds, + int(exc.safety_state.get("cooldown_remaining_seconds") or 0), + ) + session_factory = get_session_factory() + async with session_factory() as recovery_session: + await queue_service.reschedule_job( + recovery_session, + job_id=job.id, + delay_seconds=max(self._tick_seconds, cooldown_remaining_seconds), + reason="scrape_safety_cooldown", + error=str(exc.message), + ) + await recovery_session.commit() + structured_log( + logger, + "info", + "scheduler.queue_item_deferred_safety_cooldown", + queue_item_id=job.id, + user_id=job.user_id, + reason=exc.safety_state.get("cooldown_reason"), + cooldown_remaining_seconds=cooldown_remaining_seconds, + ) + + async def _reschedule_queue_job_after_exception( + self, + job: queue_service.ContinuationQueueJob, + *, + exc: Exception, + ) -> None: + session_factory = get_session_factory() + async with session_factory() as recovery_session: + queue_item = await queue_service.increment_attempt_count(recovery_session, job_id=job.id) + if queue_item is None: + await recovery_session.commit() + return + if int(queue_item.attempt_count) >= self._continuation_max_attempts: + await queue_service.mark_dropped( + recovery_session, + job_id=job.id, + reason="scheduler_exception_max_attempts", + error=str(exc), + ) + await recovery_session.commit() + structured_log( + logger, + "warning", + "scheduler.queue_item_dropped_after_exception", + queue_item_id=job.id, + user_id=job.user_id, + attempt_count=queue_item.attempt_count, + ) + return + delay_seconds = queue_service.compute_backoff_seconds( + base_seconds=self._continuation_base_delay_seconds, + attempt_count=int(queue_item.attempt_count), + max_seconds=self._continuation_max_delay_seconds, + ) + await queue_service.reschedule_job( + recovery_session, + job_id=job.id, + delay_seconds=delay_seconds, + reason="scheduler_exception", + error=str(exc), + ) + await recovery_session.commit() + structured_log( + logger, + "exception", + "scheduler.queue_item_run_failed", + queue_item_id=job.id, + user_id=job.user_id, + ) + + async def _load_request_delay_for_user(self, user_id: int, *, floor: int) -> int: + session_factory = get_session_factory() + async with session_factory() as session: + result = await session.execute( + select(UserSetting.request_delay_seconds).where(UserSetting.user_id == user_id) + ) + delay = result.scalar_one_or_none() + return effective_request_delay_seconds(delay, floor=floor) + + async def _run_ingestion_for_queue_job( + self, + job: queue_service.ContinuationQueueJob, + *, + request_delay_floor: int, + ): + request_delay_seconds = await self._load_request_delay_for_user(job.user_id, floor=request_delay_floor) + session_factory = get_session_factory() + async with session_factory() as session: + ingestion = ScholarIngestionService(source=self._source) + try: + return await ingestion.run_for_user( + session, + user_id=job.user_id, + trigger_type=RunTriggerType.SCHEDULED, + request_delay_seconds=request_delay_seconds, + network_error_retries=self._network_error_retries, + retry_backoff_seconds=self._retry_backoff_seconds, + rate_limit_retries=settings.ingestion_rate_limit_retries, + rate_limit_backoff_seconds=settings.ingestion_rate_limit_backoff_seconds, + max_pages_per_scholar=self._max_pages_per_scholar, + page_size=self._page_size, + scholar_profile_ids={job.scholar_profile_id}, + start_cstart_by_scholar_id={job.scholar_profile_id: job.resume_cstart}, + auto_queue_continuations=self._continuation_queue_enabled, + queue_delay_seconds=self._continuation_base_delay_seconds, + alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, + alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, + alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, + ) + except RunAlreadyInProgressError: + await session.rollback() + await self._reschedule_queue_job_lock_active(job) + except RunBlockedBySafetyPolicyError as exc: + await session.rollback() + await self._reschedule_queue_job_safety_cooldown(job, exc) + except Exception as exc: + await session.rollback() + await self._reschedule_queue_job_after_exception(job, exc=exc) + return None + + async def _finalize_successful_queue_job(self, session, job, run_summary) -> None: + queue_item = await queue_service.reset_attempt_count(session, job_id=job.id) + await session.commit() + if queue_item is None: + structured_log( + logger, + "info", + "scheduler.queue_item_resolved", + queue_item_id=job.id, + user_id=job.user_id, + run_id=run_summary.crawl_run_id, + status=run_summary.status.value, + ) + return + structured_log( + logger, + "info", + "scheduler.queue_item_progressed", + queue_item_id=job.id, + user_id=job.user_id, + run_id=run_summary.crawl_run_id, + status=run_summary.status.value, + attempt_count=int(queue_item.attempt_count), + ) + + async def _finalize_failed_queue_job(self, session, job, run_summary) -> None: + queue_item = await queue_service.increment_attempt_count(session, job_id=job.id) + if queue_item is None: + await session.commit() + structured_log( + logger, + "info", + "scheduler.queue_item_resolved", + queue_item_id=job.id, + user_id=job.user_id, + run_id=run_summary.crawl_run_id, + status=run_summary.status.value, + ) + return + if int(queue_item.attempt_count) >= self._continuation_max_attempts: + await queue_service.mark_dropped(session, job_id=job.id, reason="max_attempts_after_run") + await session.commit() + structured_log( + logger, + "warning", + "scheduler.queue_item_dropped_max_attempts_after_run", + queue_item_id=job.id, + user_id=job.user_id, + attempt_count=queue_item.attempt_count, + run_id=run_summary.crawl_run_id, + status=run_summary.status.value, + ) + return + delay_seconds = queue_service.compute_backoff_seconds( + base_seconds=self._continuation_base_delay_seconds, + attempt_count=int(queue_item.attempt_count), + max_seconds=self._continuation_max_delay_seconds, + ) + await queue_service.reschedule_job( + session, + job_id=job.id, + delay_seconds=delay_seconds, + reason=queue_item.reason, + error=queue_item.last_error, + ) + await session.commit() + structured_log( + logger, + "info", + "scheduler.queue_item_rescheduled_failed", + queue_item_id=job.id, + user_id=job.user_id, + run_id=run_summary.crawl_run_id, + status=run_summary.status.value, + attempt_count=int(queue_item.attempt_count), + delay_seconds=delay_seconds, + ) + + async def _finalize_queue_job_after_run(self, job: queue_service.ContinuationQueueJob, run_summary) -> None: + session_factory = get_session_factory() + async with session_factory() as session: + if int(run_summary.failed_count) <= 0: + await self._finalize_successful_queue_job(session, job, run_summary) + else: + await self._finalize_failed_queue_job(session, job, run_summary) + + async def _run_queue_job(self, job: queue_service.ContinuationQueueJob) -> None: + if await self._drop_queue_job_if_max_attempts(job): + return + if not await self._mark_queue_job_retrying(job): + return + if not await self._queue_job_has_available_scholar(job): + return + from app.services.settings import application as user_settings_service + + request_delay_floor = user_settings_service.resolve_request_delay_minimum( + settings.ingestion_min_request_delay_seconds, + ) + run_summary = await self._run_ingestion_for_queue_job(job, request_delay_floor=request_delay_floor) + if run_summary is None: + return + await self._finalize_queue_job_after_run(job, run_summary) diff --git a/app/services/ingestion/run_completion.py b/app/services/ingestion/run_completion.py new file mode 100644 index 0000000..d22b8fc --- /dev/null +++ b/app/services/ingestion/run_completion.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import hashlib +import logging +from datetime import UTC, datetime +from typing import Any + +from app.db.models import ( + CrawlRun, + RunStatus, + ScholarProfile, +) +from app.logging_utils import structured_log +from app.services.ingestion import safety as run_safety_service +from app.services.ingestion.constants import ( + FAILED_STATES, + FAILURE_BUCKET_BLOCKED, + FAILURE_BUCKET_INGESTION, + FAILURE_BUCKET_LAYOUT, + FAILURE_BUCKET_NETWORK, + FAILURE_BUCKET_OTHER, +) +from app.services.ingestion.fingerprints import _build_body_excerpt +from app.services.ingestion.types import ( + RunAlertSummary, + RunExecutionSummary, + RunFailureSummary, + RunProgress, + ScholarProcessingOutcome, +) +from app.services.scholar.parser import ParsedProfilePage, ParseState +from app.services.scholar.source import FetchResult +from app.settings import settings + +logger = logging.getLogger(__name__) + + +def int_or_default(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def classify_failure_bucket(*, state: str, state_reason: str) -> str: + reason = state_reason.strip().lower() + normalized_state = state.strip().lower() + + if normalized_state == ParseState.BLOCKED_OR_CAPTCHA.value or reason.startswith("blocked_"): + return FAILURE_BUCKET_BLOCKED + if normalized_state == ParseState.NETWORK_ERROR.value or reason.startswith("network_"): + return FAILURE_BUCKET_NETWORK + if normalized_state == ParseState.LAYOUT_CHANGED.value: + return FAILURE_BUCKET_LAYOUT + if normalized_state == "ingestion_error": + return FAILURE_BUCKET_INGESTION + return FAILURE_BUCKET_OTHER + + +def summarize_failures( + *, + scholar_results: list[dict[str, Any]], +) -> RunFailureSummary: + failed_state_counts: dict[str, int] = {} + failed_reason_counts: dict[str, int] = {} + scrape_failure_counts: dict[str, int] = {} + retries_scheduled_count = 0 + scholars_with_retries_count = 0 + retry_exhausted_count = 0 + for entry in scholar_results: + retries_for_entry = max(0, int_or_default(entry.get("attempt_count"), 0) - 1) + if retries_for_entry > 0: + retries_scheduled_count += retries_for_entry + scholars_with_retries_count += 1 + if str(entry.get("outcome", "")) != "failed": + continue + state = str(entry.get("state", "")).strip() + if state not in FAILED_STATES: + continue + failed_state_counts[state] = failed_state_counts.get(state, 0) + 1 + reason = str(entry.get("state_reason", "")).strip() + if reason: + failed_reason_counts[reason] = failed_reason_counts.get(reason, 0) + 1 + bucket = classify_failure_bucket(state=state, state_reason=reason) + scrape_failure_counts[bucket] = scrape_failure_counts.get(bucket, 0) + 1 + if state == ParseState.NETWORK_ERROR.value and retries_for_entry > 0: + retry_exhausted_count += 1 + return RunFailureSummary( + failed_state_counts=failed_state_counts, + failed_reason_counts=failed_reason_counts, + scrape_failure_counts=scrape_failure_counts, + retries_scheduled_count=retries_scheduled_count, + scholars_with_retries_count=scholars_with_retries_count, + retry_exhausted_count=retry_exhausted_count, + ) + + +def build_alert_summary( + *, + failure_summary: RunFailureSummary, + alert_blocked_failure_threshold: int, + alert_network_failure_threshold: int, + alert_retry_scheduled_threshold: int, +) -> RunAlertSummary: + blocked_failure_count = int(failure_summary.scrape_failure_counts.get(FAILURE_BUCKET_BLOCKED, 0)) + network_failure_count = int(failure_summary.scrape_failure_counts.get(FAILURE_BUCKET_NETWORK, 0)) + blocked_threshold = max(1, int(alert_blocked_failure_threshold)) + network_threshold = max(1, int(alert_network_failure_threshold)) + retry_threshold = max(1, int(alert_retry_scheduled_threshold)) + alert_flags = { + "blocked_failure_threshold_exceeded": blocked_failure_count >= blocked_threshold, + "network_failure_threshold_exceeded": network_failure_count >= network_threshold, + "retry_scheduled_threshold_exceeded": failure_summary.retries_scheduled_count >= retry_threshold, + } + return RunAlertSummary( + blocked_failure_count=blocked_failure_count, + network_failure_count=network_failure_count, + blocked_failure_threshold=blocked_threshold, + network_failure_threshold=network_threshold, + retry_scheduled_threshold=retry_threshold, + alert_flags=alert_flags, + ) + + +def apply_safety_outcome( + *, + user_settings: Any, + run: CrawlRun, + user_id: int, + alert_summary: RunAlertSummary, +) -> None: + pre_apply_state = run_safety_service.get_safety_event_context( + user_settings, + now_utc=datetime.now(UTC), + ) + safety_state, cooldown_trigger_reason = run_safety_service.apply_run_safety_outcome( + user_settings, + run_id=int(run.id), + blocked_failure_count=alert_summary.blocked_failure_count, + network_failure_count=alert_summary.network_failure_count, + blocked_failure_threshold=alert_summary.blocked_failure_threshold, + network_failure_threshold=alert_summary.network_failure_threshold, + blocked_cooldown_seconds=settings.ingestion_safety_cooldown_blocked_seconds, + network_cooldown_seconds=settings.ingestion_safety_cooldown_network_seconds, + now_utc=datetime.now(UTC), + ) + if cooldown_trigger_reason is not None: + structured_log( + logger, + "warning", + "ingestion.safety_cooldown_entered", + user_id=user_id, + crawl_run_id=int(run.id), + reason=cooldown_trigger_reason, + blocked_failure_count=alert_summary.blocked_failure_count, + network_failure_count=alert_summary.network_failure_count, + blocked_failure_threshold=alert_summary.blocked_failure_threshold, + network_failure_threshold=alert_summary.network_failure_threshold, + cooldown_until=safety_state.get("cooldown_until"), + cooldown_remaining_seconds=safety_state.get("cooldown_remaining_seconds"), + safety_counters=safety_state.get("counters", {}), + ) + elif pre_apply_state.get("cooldown_active") and not safety_state.get("cooldown_active"): + structured_log( + logger, + "info", + "ingestion.cooldown_cleared", + user_id=user_id, + crawl_run_id=int(run.id), + reason=pre_apply_state.get("cooldown_reason"), + cooldown_until=pre_apply_state.get("cooldown_until"), + ) + + +def finalize_run_record( + *, + run: CrawlRun, + scholars: list[ScholarProfile], + progress: RunProgress, + failure_summary: RunFailureSummary, + alert_summary: RunAlertSummary, + idempotency_key: str | None, + run_status: RunStatus, +) -> None: + run.end_dt = datetime.now(UTC) + if run.status != RunStatus.CANCELED: + run.status = run_status + run.error_log = { + "scholar_results": progress.scholar_results, + "summary": { + "succeeded_count": progress.succeeded_count, + "failed_count": progress.failed_count, + "partial_count": progress.partial_count, + "failed_state_counts": failure_summary.failed_state_counts, + "failed_reason_counts": failure_summary.failed_reason_counts, + "scrape_failure_counts": failure_summary.scrape_failure_counts, + "retry_counts": { + "retries_scheduled_count": failure_summary.retries_scheduled_count, + "scholars_with_retries_count": failure_summary.scholars_with_retries_count, + "retry_exhausted_count": failure_summary.retry_exhausted_count, + }, + "alert_thresholds": { + "blocked_failure_threshold": alert_summary.blocked_failure_threshold, + "network_failure_threshold": alert_summary.network_failure_threshold, + "retry_scheduled_threshold": alert_summary.retry_scheduled_threshold, + }, + "alert_flags": alert_summary.alert_flags, + }, + "meta": {"idempotency_key": idempotency_key} if idempotency_key else {}, + } + + +def resolve_run_status( + *, + scholar_count: int, + succeeded_count: int, + failed_count: int, + partial_count: int, +) -> RunStatus: + if scholar_count == 0: + return RunStatus.SUCCESS + if failed_count == scholar_count: + return RunStatus.FAILED + if failed_count > 0 or partial_count > 0: + return RunStatus.PARTIAL_FAILURE + if succeeded_count > 0: + return RunStatus.SUCCESS + return RunStatus.FAILED + + +def build_failure_debug_context( + *, + fetch_result: FetchResult, + parsed_page: ParsedProfilePage, + attempt_log: list[dict[str, Any]], + page_logs: list[dict[str, Any]] | None = None, + exception: Exception | None = None, +) -> dict[str, Any]: + context: dict[str, Any] = { + "requested_url": fetch_result.requested_url, + "final_url": fetch_result.final_url, + "status_code": fetch_result.status_code, + "fetch_error": fetch_result.error, + "state_reason": parsed_page.state_reason, + "profile_name": parsed_page.profile_name, + "profile_image_url": parsed_page.profile_image_url, + "articles_range": parsed_page.articles_range, + "has_show_more_button": parsed_page.has_show_more_button, + "has_operation_error_banner": parsed_page.has_operation_error_banner, + "warning_codes": parsed_page.warnings, + "marker_counts_nonzero": {key: value for key, value in parsed_page.marker_counts.items() if value > 0}, + "body_length": len(fetch_result.body), + "body_sha256": hashlib.sha256(fetch_result.body.encode("utf-8")).hexdigest() if fetch_result.body else None, + "body_excerpt": _build_body_excerpt(fetch_result.body), + "attempt_log": attempt_log, + } + if page_logs: + context["page_logs"] = page_logs + if exception is not None: + context["exception_type"] = type(exception).__name__ + context["exception_message"] = str(exception) + return context + + +def _log_alert_threshold_warnings( + *, + user_id: int, + run: CrawlRun, + failure_summary: RunFailureSummary, + alert_summary: RunAlertSummary, +) -> None: + if alert_summary.alert_flags["blocked_failure_threshold_exceeded"]: + structured_log( + logger, + "warning", + "ingestion.alert_blocked_failure_threshold_exceeded", + user_id=user_id, + crawl_run_id=int(run.id), + blocked_failure_count=alert_summary.blocked_failure_count, + threshold=alert_summary.blocked_failure_threshold, + ) + if alert_summary.alert_flags["network_failure_threshold_exceeded"]: + structured_log( + logger, + "warning", + "ingestion.alert_network_failure_threshold_exceeded", + user_id=user_id, + crawl_run_id=int(run.id), + network_failure_count=alert_summary.network_failure_count, + threshold=alert_summary.network_failure_threshold, + ) + if alert_summary.alert_flags["retry_scheduled_threshold_exceeded"]: + structured_log( + logger, + "warning", + "ingestion.alert_retry_scheduled_threshold_exceeded", + user_id=user_id, + crawl_run_id=int(run.id), + retries_scheduled_count=failure_summary.retries_scheduled_count, + threshold=alert_summary.retry_scheduled_threshold, + ) + + +def complete_run_for_user( + *, + user_settings: Any, + run: CrawlRun, + scholars: list[ScholarProfile], + user_id: int, + progress: RunProgress, + idempotency_key: str | None, + alert_blocked_failure_threshold: int, + alert_network_failure_threshold: int, + alert_retry_scheduled_threshold: int, +) -> tuple[RunFailureSummary, RunAlertSummary]: + failure_summary = summarize_failures(scholar_results=progress.scholar_results) + alert_summary = build_alert_summary( + failure_summary=failure_summary, + alert_blocked_failure_threshold=alert_blocked_failure_threshold, + alert_network_failure_threshold=alert_network_failure_threshold, + alert_retry_scheduled_threshold=alert_retry_scheduled_threshold, + ) + _log_alert_threshold_warnings( + user_id=user_id, run=run, failure_summary=failure_summary, alert_summary=alert_summary + ) + apply_safety_outcome(user_settings=user_settings, run=run, user_id=user_id, alert_summary=alert_summary) + run_status = resolve_run_status( + scholar_count=len(scholars), + succeeded_count=progress.succeeded_count, + failed_count=progress.failed_count, + partial_count=progress.partial_count, + ) + finalize_run_record( + run=run, + scholars=scholars, + progress=progress, + failure_summary=failure_summary, + alert_summary=alert_summary, + idempotency_key=idempotency_key, + run_status=run_status, + ) + return failure_summary, alert_summary + + +def result_counters(result_entry: dict[str, Any]) -> tuple[int, int, int]: + outcome = str(result_entry.get("outcome", "")).strip().lower() + if outcome == "success": + return 1, 0, 0 + if outcome == "partial": + return 1, 0, 1 + if outcome == "failed": + return 0, 1, 0 + raise RuntimeError(f"Unexpected scholar outcome label: {outcome!r}") + + +def find_scholar_result_index( + *, + scholar_results: list[dict[str, Any]], + scholar_profile_id: int, +) -> int | None: + for index, result_entry in enumerate(scholar_results): + current_scholar_id = int_or_default(result_entry.get("scholar_profile_id"), 0) + if current_scholar_id == scholar_profile_id: + return index + return None + + +def adjust_progress_counts( + *, + progress: RunProgress, + succeeded_delta: int, + failed_delta: int, + partial_delta: int, +) -> None: + progress.succeeded_count += succeeded_delta + progress.failed_count += failed_delta + progress.partial_count += partial_delta + if progress.succeeded_count < 0 or progress.failed_count < 0 or progress.partial_count < 0: + raise RuntimeError("RunProgress counters entered invalid negative state.") + + +def apply_outcome_to_progress( + *, + progress: RunProgress, + outcome: ScholarProcessingOutcome, +) -> None: + scholar_profile_id = int_or_default(outcome.result_entry.get("scholar_profile_id"), 0) + if scholar_profile_id <= 0: + raise RuntimeError("Scholar outcome missing valid scholar_profile_id.") + prior_index = find_scholar_result_index( + scholar_results=progress.scholar_results, + scholar_profile_id=scholar_profile_id, + ) + next_succeeded, next_failed, next_partial = result_counters(outcome.result_entry) + if prior_index is None: + progress.scholar_results.append(outcome.result_entry) + adjust_progress_counts( + progress=progress, + succeeded_delta=next_succeeded, + failed_delta=next_failed, + partial_delta=next_partial, + ) + return + previous_entry = progress.scholar_results[prior_index] + prev_succeeded, prev_failed, prev_partial = result_counters(previous_entry) + progress.scholar_results[prior_index] = outcome.result_entry + adjust_progress_counts( + progress=progress, + succeeded_delta=next_succeeded - prev_succeeded, + failed_delta=next_failed - prev_failed, + partial_delta=next_partial - prev_partial, + ) + + +def run_execution_summary( + *, + run: CrawlRun, + scholars: list[ScholarProfile], + progress: RunProgress, +) -> RunExecutionSummary: + return RunExecutionSummary( + crawl_run_id=run.id, + status=run.status, + scholar_count=len(scholars), + succeeded_count=progress.succeeded_count, + failed_count=progress.failed_count, + partial_count=progress.partial_count, + new_publication_count=run.new_pub_count, + ) + + +def log_run_completed( + *, + run: CrawlRun, + user_id: int, + scholars: list[ScholarProfile], + progress: RunProgress, + failure_summary: RunFailureSummary, + alert_summary: RunAlertSummary, +) -> None: + structured_log( + logger, + "info", + "ingestion.run_completed", + user_id=user_id, + crawl_run_id=run.id, + status=run.status.value, + scholar_count=len(scholars), + succeeded_count=progress.succeeded_count, + failed_count=progress.failed_count, + partial_count=progress.partial_count, + new_publication_count=run.new_pub_count, + blocked_failure_count=alert_summary.blocked_failure_count, + network_failure_count=alert_summary.network_failure_count, + retries_scheduled_count=failure_summary.retries_scheduled_count, + alert_flags=alert_summary.alert_flags, + ) diff --git a/app/services/ingestion/run_enrichment.py b/app/services/ingestion/run_enrichment.py new file mode 100644 index 0000000..94ed0f4 --- /dev/null +++ b/app/services/ingestion/run_enrichment.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import CrawlRun, RunStatus +from app.logging_utils import structured_log +from app.services.ingestion.enrichment import EnrichmentRunner + +logger = logging.getLogger(__name__) + +_background_tasks: set[asyncio.Task[Any]] = set() + + +async def background_enrich( + session_factory: Any, + enrichment_runner: EnrichmentRunner, + *, + run_id: int, + intended_final_status: RunStatus, + openalex_api_key: str | None = None, +) -> None: + try: + async with session_factory() as session: + await enrichment_runner.enrich_pending_publications( + session, + run_id=run_id, + openalex_api_key=openalex_api_key, + ) + run = await session.get(CrawlRun, run_id) + if run is not None and run.status == RunStatus.RESOLVING: + run.status = intended_final_status + await session.commit() + structured_log( + logger, + "info", + "ingestion.background_enrichment_complete", + run_id=run_id, + final_status=str(intended_final_status), + ) + except Exception: + structured_log( + logger, + "exception", + "ingestion.background_enrichment_failed", + run_id=run_id, + ) + try: + async with session_factory() as fallback_session: + run = await fallback_session.get(CrawlRun, run_id) + if run is not None and run.status == RunStatus.RESOLVING: + run.status = intended_final_status + await fallback_session.commit() + except Exception: + structured_log( + logger, + "exception", + "ingestion.background_enrichment_fallback_failed", + run_id=run_id, + ) + + +async def inline_enrich_and_finalize( + db_session: AsyncSession, + enrichment_runner: EnrichmentRunner, + *, + run: CrawlRun, + user_settings: Any, + intended_final_status: RunStatus, +) -> None: + try: + await enrichment_runner.enrich_pending_publications( + db_session, + run_id=run.id, + openalex_api_key=getattr(user_settings, "openalex_api_key", None), + ) + except Exception: + structured_log( + logger, + "exception", + "ingestion.enrichment_failed", + run_id=run.id, + ) + if run.status == RunStatus.RESOLVING: + run.status = intended_final_status + await db_session.commit() + + +def spawn_background_enrichment_task( + session_factory: Any, + enrichment_runner: EnrichmentRunner, + *, + run_id: int, + intended_final_status: RunStatus, + openalex_api_key: str | None, +) -> None: + task = asyncio.create_task( + background_enrich( + session_factory, + enrichment_runner, + run_id=run_id, + intended_final_status=intended_final_status, + openalex_api_key=openalex_api_key, + ) + ) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) diff --git a/app/services/ingestion/run_guards.py b/app/services/ingestion/run_guards.py new file mode 100644 index 0000000..fafc405 --- /dev/null +++ b/app/services/ingestion/run_guards.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ( + RunTriggerType, + ScholarProfile, +) +from app.logging_utils import structured_log +from app.services.ingestion import safety as run_safety_service +from app.services.ingestion.preflight import check_scholar_reachable +from app.services.ingestion.types import RunBlockedBySafetyPolicyError +from app.services.scholar.source import ScholarSource +from app.services.settings import application as user_settings_service +from app.settings import settings + +logger = logging.getLogger(__name__) + + +async def load_user_settings_for_run( + db_session: AsyncSession, + *, + user_id: int, + trigger_type: RunTriggerType, +): + user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=user_id) + await enforce_safety_gate(db_session, user_settings=user_settings, user_id=user_id, trigger_type=trigger_type) + return user_settings + + +async def enforce_safety_gate( + db_session: AsyncSession, + *, + user_settings, + user_id: int, + trigger_type: RunTriggerType, +) -> None: + now_utc = datetime.now(UTC) + previous = run_safety_service.get_safety_event_context(user_settings, now_utc=now_utc) + if run_safety_service.clear_expired_cooldown(user_settings, now_utc=now_utc): + await db_session.commit() + await db_session.refresh(user_settings) + structured_log( + logger, + "info", + "ingestion.cooldown_cleared", + user_id=user_id, + reason=previous.get("cooldown_reason"), + cooldown_until=previous.get("cooldown_until"), + ) + now_utc = datetime.now(UTC) + if run_safety_service.is_cooldown_active(user_settings, now_utc=now_utc): + await raise_safety_blocked_start( + db_session, + user_settings=user_settings, + user_id=user_id, + trigger_type=trigger_type, + now_utc=now_utc, + ) + + +async def raise_safety_blocked_start( + db_session: AsyncSession, + *, + user_settings, + user_id: int, + trigger_type: RunTriggerType, + now_utc: datetime, +) -> None: + safety_state = run_safety_service.register_cooldown_blocked_start(user_settings, now_utc=now_utc) + await db_session.commit() + structured_log( + logger, + "warning", + "ingestion.safety_policy_blocked_run_start", + user_id=user_id, + trigger_type=trigger_type.value, + reason=safety_state.get("cooldown_reason"), + cooldown_until=safety_state.get("cooldown_until"), + cooldown_remaining_seconds=safety_state.get("cooldown_remaining_seconds"), + blocked_start_count=((safety_state.get("counters") or {}).get("blocked_start_count")), + ) + raise RunBlockedBySafetyPolicyError( + code="scrape_cooldown_active", + message="Scrape safety cooldown is active; run start is temporarily blocked.", + safety_state=safety_state, + ) + + +async def run_preflight_guard( + db_session: AsyncSession, + source: ScholarSource, + *, + user_settings: Any, + user_id: int, + scholars: list[ScholarProfile], +) -> None: + if not scholars: + return + result = await check_scholar_reachable( + source, + scholar_id=scholars[0].scholar_id, + ) + if result.passed: + return + now_utc = datetime.now(UTC) + safety_state, _ = run_safety_service.apply_run_safety_outcome( + user_settings, + run_id=0, + blocked_failure_count=1, + network_failure_count=0, + blocked_failure_threshold=1, + network_failure_threshold=1, + blocked_cooldown_seconds=settings.ingestion_safety_cooldown_blocked_seconds, + network_cooldown_seconds=settings.ingestion_safety_cooldown_network_seconds, + now_utc=now_utc, + ) + await db_session.commit() + structured_log( + logger, + "warning", + "ingestion.cooldown_entered_preflight", + user_id=user_id, + block_reason=result.block_reason, + cooldown_until=safety_state.get("cooldown_until"), + ) + raise RunBlockedBySafetyPolicyError( + code="scrape_cooldown_active", + message=f"Preflight detected Scholar block ({result.block_reason}). Cooldown activated.", + safety_state=safety_state, + ) diff --git a/app/services/domains/ingestion/safety.py b/app/services/ingestion/safety.py similarity index 94% rename from app/services/domains/ingestion/safety.py rename to app/services/ingestion/safety.py index debb8c5..a8385a9 100644 --- a/app/services/domains/ingestion/safety.py +++ b/app/services/ingestion/safety.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Any from app.db.models import UserSetting @@ -18,7 +18,7 @@ _COUNTER_LAST_EVALUATED_RUN_ID = "last_evaluated_run_id" def _utcnow() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) def _safe_int(value: Any, default: int = 0) -> int: @@ -41,8 +41,8 @@ def _normalize_datetime(value: datetime | None) -> datetime | None: if value is None: return None if value.tzinfo is None: - return value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc) + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) def _state_dict(settings: UserSetting) -> dict[str, Any]: @@ -100,9 +100,7 @@ def _recommended_action(reason: str | None) -> str | None: "increase request delay, and avoid repeated manual retries." ) if reason == COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD: - return ( - "Network failures crossed the threshold. Verify connectivity and retry after cooldown." - ) + return "Network failures crossed the threshold. Verify connectivity and retry after cooldown." return None @@ -159,14 +157,10 @@ def _update_run_counters( counters[_COUNTER_LAST_NETWORK_FAILURE_COUNT] = bounded_network_failures counters[_COUNTER_LAST_EVALUATED_RUN_ID] = int(run_id) counters[_COUNTER_CONSECUTIVE_BLOCKED_RUNS] = ( - int(counters[_COUNTER_CONSECUTIVE_BLOCKED_RUNS]) + 1 - if bounded_blocked_failures > 0 - else 0 + int(counters[_COUNTER_CONSECUTIVE_BLOCKED_RUNS]) + 1 if bounded_blocked_failures > 0 else 0 ) counters[_COUNTER_CONSECUTIVE_NETWORK_RUNS] = ( - int(counters[_COUNTER_CONSECUTIVE_NETWORK_RUNS]) + 1 - if bounded_network_failures > 0 - else 0 + int(counters[_COUNTER_CONSECUTIVE_NETWORK_RUNS]) + 1 if bounded_network_failures > 0 else 0 ) return bounded_blocked_failures, bounded_network_failures diff --git a/app/services/ingestion/scheduler.py b/app/services/ingestion/scheduler.py new file mode 100644 index 0000000..bf4e9e7 --- /dev/null +++ b/app/services/ingestion/scheduler.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any + +from sqlalchemy import select + +from app.db.models import ( + CrawlRun, + RunTriggerType, + User, + UserSetting, +) +from app.db.session import get_session_factory +from app.logging_utils import structured_log +from app.services.ingestion.application import ( + RunAlreadyInProgressError, + RunBlockedBySafetyPolicyError, + ScholarIngestionService, +) +from app.services.ingestion.queue_runner import QueueJobRunner, effective_request_delay_seconds +from app.services.scholar.source import LiveScholarSource +from app.services.settings import application as user_settings_service +from app.settings import settings + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _AutoRunCandidate: + user_id: int + run_interval_minutes: int + request_delay_seconds: int + cooldown_until: datetime | None + cooldown_reason: str | None + + +class SchedulerService: + def __init__( + self, + *, + enabled: bool, + tick_seconds: int, + network_error_retries: int, + retry_backoff_seconds: float, + max_pages_per_scholar: int, + page_size: int, + continuation_queue_enabled: bool, + continuation_base_delay_seconds: int, + continuation_max_delay_seconds: int, + continuation_max_attempts: int, + queue_batch_size: int, + ) -> None: + self._enabled = enabled + self._tick_seconds = max(5, int(tick_seconds)) + self._network_error_retries = max(0, int(network_error_retries)) + self._retry_backoff_seconds = max(0.0, float(retry_backoff_seconds)) + self._max_pages_per_scholar = max(1, int(max_pages_per_scholar)) + self._page_size = max(1, int(page_size)) + self._continuation_queue_enabled = bool(continuation_queue_enabled) + self._continuation_base_delay_seconds = max(1, int(continuation_base_delay_seconds)) + self._continuation_max_delay_seconds = max( + self._continuation_base_delay_seconds, + int(continuation_max_delay_seconds), + ) + self._continuation_max_attempts = max(1, int(continuation_max_attempts)) + self._queue_batch_size = max(1, int(queue_batch_size)) + self._task: asyncio.Task[None] | None = None + self._source = LiveScholarSource() + self._queue_runner = QueueJobRunner( + tick_seconds=self._tick_seconds, + network_error_retries=self._network_error_retries, + retry_backoff_seconds=self._retry_backoff_seconds, + max_pages_per_scholar=self._max_pages_per_scholar, + page_size=self._page_size, + continuation_queue_enabled=self._continuation_queue_enabled, + continuation_base_delay_seconds=self._continuation_base_delay_seconds, + continuation_max_delay_seconds=self._continuation_max_delay_seconds, + continuation_max_attempts=self._continuation_max_attempts, + queue_batch_size=self._queue_batch_size, + ) + + async def start(self) -> None: + if not self._enabled: + structured_log(logger, "info", "scheduler.disabled") + return + if self._task is not None: + return + self._task = asyncio.create_task(self._run_loop(), name="scholarr-scheduler") + structured_log( + logger, + "info", + "scheduler.started", + tick_seconds=self._tick_seconds, + network_error_retries=self._network_error_retries, + retry_backoff_seconds=self._retry_backoff_seconds, + max_pages_per_scholar=self._max_pages_per_scholar, + page_size=self._page_size, + continuation_queue_enabled=self._continuation_queue_enabled, + continuation_base_delay_seconds=self._continuation_base_delay_seconds, + continuation_max_delay_seconds=self._continuation_max_delay_seconds, + continuation_max_attempts=self._continuation_max_attempts, + queue_batch_size=self._queue_batch_size, + ) + + async def stop(self) -> None: + if self._task is None: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + finally: + self._task = None + structured_log(logger, "info", "scheduler.stopped") + + async def _run_loop(self) -> None: + while True: + try: + await self._tick_once() + except asyncio.CancelledError: + raise + except Exception: + structured_log( + logger, + "exception", + "scheduler.tick_failed", + ) + await asyncio.sleep(float(self._tick_seconds)) + + async def _tick_once(self) -> None: + if self._continuation_queue_enabled: + await self._queue_runner.drain_continuation_queue() + + await self._drain_pdf_queue() + + candidates = await self._load_candidates() + if not candidates: + return + now = datetime.now(UTC) + for candidate in candidates: + if not await self._is_due(candidate, now=now): + continue + await self._run_candidate(candidate) + + async def _load_candidate_rows(self) -> list[Any]: + session_factory = get_session_factory() + async with session_factory() as session: + result = await session.execute( + select( + UserSetting.user_id, + UserSetting.run_interval_minutes, + UserSetting.request_delay_seconds, + UserSetting.scrape_cooldown_until, + UserSetting.scrape_cooldown_reason, + ) + .join(User, User.id == UserSetting.user_id) + .where(User.is_active.is_(True), UserSetting.auto_run_enabled.is_(True)) + .order_by(UserSetting.user_id.asc()) + ) + return list(result.all()) + + @staticmethod + def _candidate_from_row(row: Any, *, now_utc: datetime) -> _AutoRunCandidate | None: + user_id, run_interval_minutes, request_delay_seconds, cooldown_until, cooldown_reason = row + if cooldown_until is not None and cooldown_until.tzinfo is None: + cooldown_until = cooldown_until.replace(tzinfo=UTC) + if cooldown_until is not None and cooldown_until > now_utc: + structured_log( + logger, + "info", + "scheduler.run_skipped_safety_cooldown_precheck", + user_id=int(user_id), + reason=cooldown_reason, + cooldown_until=cooldown_until, + cooldown_remaining_seconds=int((cooldown_until - now_utc).total_seconds()), + ) + return None + return _AutoRunCandidate( + user_id=int(user_id), + run_interval_minutes=int(run_interval_minutes), + request_delay_seconds=effective_request_delay_seconds( + request_delay_seconds, + floor=user_settings_service.resolve_request_delay_minimum(settings.ingestion_min_request_delay_seconds), + ), + cooldown_until=cooldown_until, + cooldown_reason=(str(cooldown_reason).strip() if cooldown_reason else None), + ) + + async def _load_candidates(self) -> list[_AutoRunCandidate]: + if not settings.ingestion_automation_allowed: + return [] + rows = await self._load_candidate_rows() + now_utc = datetime.now(UTC) + candidates: list[_AutoRunCandidate] = [] + for row in rows: + candidate = self._candidate_from_row(row, now_utc=now_utc) + if candidate is not None: + candidates.append(candidate) + return candidates + + async def _is_due(self, candidate: _AutoRunCandidate, *, now: datetime) -> bool: + session_factory = get_session_factory() + async with session_factory() as session: + result = await session.execute( + select(CrawlRun.start_dt) + .where( + CrawlRun.user_id == candidate.user_id, + ) + .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) + .limit(1) + ) + last_run = result.scalar_one_or_none() + + if last_run is None: + return True + + next_due_dt = last_run + timedelta(minutes=candidate.run_interval_minutes) + return now >= next_due_dt + + async def _run_candidate_ingestion( + self, + *, + candidate: _AutoRunCandidate, + ): + session_factory = get_session_factory() + async with session_factory() as session: + ingestion = ScholarIngestionService(source=self._source) + try: + return await ingestion.run_for_user( + session, + user_id=candidate.user_id, + trigger_type=RunTriggerType.SCHEDULED, + request_delay_seconds=candidate.request_delay_seconds, + network_error_retries=self._network_error_retries, + retry_backoff_seconds=self._retry_backoff_seconds, + max_pages_per_scholar=self._max_pages_per_scholar, + page_size=self._page_size, + auto_queue_continuations=self._continuation_queue_enabled, + queue_delay_seconds=self._continuation_base_delay_seconds, + alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold, + alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold, + alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold, + ) + except RunAlreadyInProgressError: + await session.rollback() + structured_log(logger, "info", "scheduler.run_skipped_locked", user_id=candidate.user_id) + return None + except RunBlockedBySafetyPolicyError as exc: + await session.rollback() + structured_log( + logger, + "info", + "scheduler.run_skipped_safety_cooldown", + user_id=candidate.user_id, + reason=exc.safety_state.get("cooldown_reason"), + cooldown_until=exc.safety_state.get("cooldown_until"), + cooldown_remaining_seconds=exc.safety_state.get("cooldown_remaining_seconds"), + ) + return None + except Exception: + await session.rollback() + structured_log( + logger, + "exception", + "scheduler.run_failed", + user_id=candidate.user_id, + ) + return None + + async def _run_candidate(self, candidate: _AutoRunCandidate) -> None: + run_summary = await self._run_candidate_ingestion(candidate=candidate) + if run_summary is None: + return + structured_log( + logger, + "info", + "scheduler.run_completed", + user_id=candidate.user_id, + run_id=run_summary.crawl_run_id, + status=run_summary.status.value, + scholar_count=run_summary.scholar_count, + new_publication_count=run_summary.new_publication_count, + ) + + async def _drain_pdf_queue(self) -> None: + from app.services.publications.pdf_queue import drain_ready_jobs + + session_factory = get_session_factory() + async with session_factory() as session: + try: + processed = await drain_ready_jobs( + session, + limit=settings.scheduler_pdf_queue_batch_size, + max_attempts=settings.pdf_auto_retry_max_attempts, + ) + if processed > 0: + structured_log( + logger, + "info", + "scheduler.pdf_queue_drain_completed", + processed_count=processed, + ) + except Exception: + structured_log( + logger, + "exception", + "scheduler.pdf_queue_drain_failed", + ) diff --git a/app/services/ingestion/scholar_outcomes.py b/app/services/ingestion/scholar_outcomes.py new file mode 100644 index 0000000..0871273 --- /dev/null +++ b/app/services/ingestion/scholar_outcomes.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ( + CrawlRun, + RunStatus, + ScholarProfile, +) +from app.logging_utils import structured_log +from app.services.ingestion.run_completion import build_failure_debug_context +from app.services.ingestion.types import ( + PagedParseResult, + ScholarProcessingOutcome, +) +from app.services.scholar.parser import ParseState + +logger = logging.getLogger(__name__) + + +def assert_valid_paged_parse_result( + *, + scholar_id: str, + paged_parse_result: PagedParseResult, +) -> None: + parsed_page = paged_parse_result.parsed_page + if parsed_page.state in {ParseState.OK, ParseState.NO_RESULTS} and any( + code.startswith("layout_") for code in parsed_page.warnings + ): + raise RuntimeError(f"Layout warning marked as terminal for scholar_id={scholar_id}.") + for publication in paged_parse_result.publications: + if not publication.title.strip(): + raise RuntimeError(f"Malformed publication title for scholar_id={scholar_id}.") + if publication.citation_count is not None and int(publication.citation_count) < 0: + raise RuntimeError(f"Negative citation count for scholar_id={scholar_id}.") + + +def apply_first_page_profile_metadata( + *, + scholar: ScholarProfile, + paged_parse_result: PagedParseResult, + run_dt: datetime, +) -> None: + first_page = paged_parse_result.first_page_parsed_page + if first_page.profile_name and not (scholar.display_name or "").strip(): + scholar.display_name = first_page.profile_name + if first_page.profile_image_url: + scholar.profile_image_url = first_page.profile_image_url + scholar.last_initial_page_checked_at = run_dt + + +def build_result_entry( + *, + scholar: ScholarProfile, + start_cstart: int, + paged_parse_result: PagedParseResult, +) -> dict[str, Any]: + parsed_page = paged_parse_result.parsed_page + return { + "scholar_profile_id": scholar.id, + "scholar_id": scholar.scholar_id, + "state": parsed_page.state.value, + "state_reason": parsed_page.state_reason, + "outcome": "failed", + "attempt_count": len(paged_parse_result.attempt_log), + "publication_count": len(paged_parse_result.publications), + "start_cstart": start_cstart, + "articles_range": parsed_page.articles_range, + "warnings": parsed_page.warnings, + "has_show_more_button": parsed_page.has_show_more_button, + "pages_fetched": paged_parse_result.pages_fetched, + "pages_attempted": paged_parse_result.pages_attempted, + "has_more_remaining": paged_parse_result.has_more_remaining, + "pagination_truncated_reason": paged_parse_result.pagination_truncated_reason, + "continuation_cstart": paged_parse_result.continuation_cstart, + "skipped_no_change": paged_parse_result.skipped_no_change, + "initial_page_fingerprint_sha256": paged_parse_result.first_page_fingerprint_sha256, + } + + +def skipped_no_change_outcome( + *, + scholar: ScholarProfile, + run_dt: datetime, + paged_parse_result: PagedParseResult, + result_entry: dict[str, Any], +) -> ScholarProcessingOutcome: + first_page = paged_parse_result.first_page_parsed_page + scholar.last_run_status = RunStatus.SUCCESS + scholar.last_run_dt = run_dt + result_entry["state"] = first_page.state.value + result_entry["state_reason"] = "no_change_initial_page_signature" + result_entry["outcome"] = "success" + result_entry["publication_count"] = 0 + result_entry["warnings"] = first_page.warnings + result_entry["debug"] = { + "state_reason": "no_change_initial_page_signature", + "first_page_fingerprint_sha256": paged_parse_result.first_page_fingerprint_sha256, + "attempt_log": paged_parse_result.attempt_log, + "page_logs": paged_parse_result.page_logs, + } + return ScholarProcessingOutcome( + result_entry=result_entry, + succeeded_count_delta=1, + failed_count_delta=0, + partial_count_delta=0, + discovered_publication_count=0, + ) + + +async def upsert_publications_outcome( + db_session: AsyncSession, + *, + run: CrawlRun, + scholar: ScholarProfile, + run_dt: datetime, + paged_parse_result: PagedParseResult, + result_entry: dict[str, Any], +) -> ScholarProcessingOutcome: + parsed_page = paged_parse_result.parsed_page + publications = paged_parse_result.publications + had_page_failure = parsed_page.state not in {ParseState.OK, ParseState.NO_RESULTS} + has_partial_set = len(publications) > 0 and had_page_failure + if (not had_page_failure) or has_partial_set: + return await _upsert_success_or_exception( + db_session, + run=run, + scholar=scholar, + run_dt=run_dt, + paged_parse_result=paged_parse_result, + result_entry=result_entry, + has_partial_publication_set=has_partial_set, + ) + return _parse_failure_outcome( + scholar=scholar, + run_dt=run_dt, + paged_parse_result=paged_parse_result, + result_entry=result_entry, + ) + + +async def _upsert_success_or_exception( + db_session: AsyncSession, + *, + run: CrawlRun, + scholar: ScholarProfile, + run_dt: datetime, + paged_parse_result: PagedParseResult, + result_entry: dict[str, Any], + has_partial_publication_set: bool, +) -> ScholarProcessingOutcome: + try: + return _upsert_success( + run=run, + scholar=scholar, + run_dt=run_dt, + paged_parse_result=paged_parse_result, + result_entry=result_entry, + has_partial_publication_set=has_partial_publication_set, + ) + except Exception as exc: + return _upsert_exception_outcome( + run=run, + scholar=scholar, + run_dt=run_dt, + paged_parse_result=paged_parse_result, + result_entry=result_entry, + exc=exc, + ) + + +def _upsert_success( + *, + run: CrawlRun, + scholar: ScholarProfile, + run_dt: datetime, + paged_parse_result: PagedParseResult, + result_entry: dict[str, Any], + has_partial_publication_set: bool, +) -> ScholarProcessingOutcome: + discovered_count = paged_parse_result.discovered_publication_count + is_partial = ( + paged_parse_result.has_more_remaining + or paged_parse_result.pagination_truncated_reason is not None + or has_partial_publication_set + ) + scholar.last_run_status = RunStatus.PARTIAL_FAILURE if is_partial else RunStatus.SUCCESS + scholar.last_run_dt = run_dt + if not is_partial and paged_parse_result.first_page_fingerprint_sha256: + scholar.last_initial_page_fingerprint_sha256 = paged_parse_result.first_page_fingerprint_sha256 + result_entry["outcome"] = "partial" if is_partial else "success" + if is_partial: + result_entry["debug"] = build_failure_debug_context( + fetch_result=paged_parse_result.fetch_result, + parsed_page=paged_parse_result.parsed_page, + attempt_log=paged_parse_result.attempt_log, + page_logs=paged_parse_result.page_logs, + ) + return ScholarProcessingOutcome( + result_entry=result_entry, + succeeded_count_delta=1, + failed_count_delta=0, + partial_count_delta=1 if is_partial else 0, + discovered_publication_count=discovered_count, + ) + + +def _upsert_exception_outcome( + *, + run: CrawlRun, + scholar: ScholarProfile, + run_dt: datetime, + paged_parse_result: PagedParseResult, + result_entry: dict[str, Any], + exc: Exception, +) -> ScholarProcessingOutcome: + scholar.last_run_status = RunStatus.FAILED + scholar.last_run_dt = run_dt + result_entry["state"] = "ingestion_error" + result_entry["state_reason"] = "publication_upsert_exception" + result_entry["outcome"] = "failed" + result_entry["error"] = str(exc) + result_entry["debug"] = build_failure_debug_context( + fetch_result=paged_parse_result.fetch_result, + parsed_page=paged_parse_result.parsed_page, + attempt_log=paged_parse_result.attempt_log, + page_logs=paged_parse_result.page_logs, + exception=exc, + ) + structured_log( + logger, + "exception", + "ingestion.scholar_failed", + crawl_run_id=run.id, + scholar_profile_id=scholar.id, + scholar_id=scholar.scholar_id, + ) + return ScholarProcessingOutcome(result_entry, 0, 1, 0, 0) + + +def _parse_failure_outcome( + *, + scholar: ScholarProfile, + run_dt: datetime, + paged_parse_result: PagedParseResult, + result_entry: dict[str, Any], +) -> ScholarProcessingOutcome: + scholar.last_run_status = RunStatus.FAILED + scholar.last_run_dt = run_dt + result_entry["debug"] = build_failure_debug_context( + fetch_result=paged_parse_result.fetch_result, + parsed_page=paged_parse_result.parsed_page, + attempt_log=paged_parse_result.attempt_log, + page_logs=paged_parse_result.page_logs, + ) + structured_log( + logger, + "warning", + "ingestion.scholar_parse_failed", + scholar_profile_id=scholar.id, + scholar_id=scholar.scholar_id, + state=paged_parse_result.parsed_page.state.value, + state_reason=paged_parse_result.parsed_page.state_reason, + status_code=paged_parse_result.fetch_result.status_code, + ) + return ScholarProcessingOutcome(result_entry, 0, 1, 0, 0) + + +def unexpected_scholar_exception_outcome( + *, + run: CrawlRun, + scholar: ScholarProfile, + start_cstart: int, + exc: Exception, +) -> ScholarProcessingOutcome: + scholar.last_run_status = RunStatus.FAILED + scholar.last_run_dt = datetime.now(UTC) + structured_log( + logger, + "exception", + "ingestion.scholar_unexpected_failure", + crawl_run_id=run.id, + scholar_profile_id=scholar.id, + scholar_id=scholar.scholar_id, + ) + return ScholarProcessingOutcome( + result_entry={ + "scholar_profile_id": scholar.id, + "scholar_id": scholar.scholar_id, + "state": "ingestion_error", + "state_reason": "scholar_processing_exception", + "outcome": "failed", + "attempt_count": 0, + "publication_count": 0, + "start_cstart": start_cstart, + "warnings": [], + "error": str(exc), + "debug": {"exception_type": type(exc).__name__, "exception_message": str(exc)}, + }, + succeeded_count_delta=0, + failed_count_delta=1, + partial_count_delta=0, + discovered_publication_count=0, + ) diff --git a/app/services/ingestion/scholar_processing.py b/app/services/ingestion/scholar_processing.py new file mode 100644 index 0000000..f41d904 --- /dev/null +++ b/app/services/ingestion/scholar_processing.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import asyncio +import logging +import random +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy.exc import InterfaceError, OperationalError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ( + CrawlRun, + RunStatus, + ScholarProfile, +) +from app.logging_utils import structured_log +from app.services.ingestion import queue as queue_service +from app.services.ingestion.constants import ( + RESUMABLE_PARTIAL_REASON_PREFIXES, + RESUMABLE_PARTIAL_REASONS, +) +from app.services.ingestion.pagination import PaginationEngine +from app.services.ingestion.publication_upsert import upsert_profile_publications +from app.services.ingestion.run_completion import apply_outcome_to_progress +from app.services.ingestion.scholar_outcomes import ( + apply_first_page_profile_metadata, + assert_valid_paged_parse_result, + build_result_entry, + skipped_no_change_outcome, + unexpected_scholar_exception_outcome, + upsert_publications_outcome, +) +from app.services.ingestion.types import ( + PagedParseResult, + RunProgress, + ScholarProcessingOutcome, +) +from app.services.scholar.parser import ParseState +from app.services.scholar.state_detection import is_hard_challenge_reason + +logger = logging.getLogger(__name__) + + +async def sync_continuation_queue( + db_session: AsyncSession, + *, + user_id: int, + scholar: ScholarProfile, + run: CrawlRun, + start_cstart: int, + result_entry: dict[str, Any], + paged_parse_result: PagedParseResult, + auto_queue_continuations: bool, + queue_delay_seconds: int, +) -> None: + queue_reason, queue_cstart = resolve_continuation_queue_target( + outcome=str(result_entry.get("outcome", "")), + state=str(result_entry.get("state", "")), + pagination_truncated_reason=paged_parse_result.pagination_truncated_reason, + continuation_cstart=paged_parse_result.continuation_cstart, + fallback_cstart=start_cstart, + ) + if auto_queue_continuations and queue_reason is not None: + await queue_service.upsert_job( + db_session, + user_id=user_id, + scholar_profile_id=scholar.id, + resume_cstart=queue_cstart, + reason=queue_reason, + run_id=run.id, + delay_seconds=queue_delay_seconds, + ) + result_entry["continuation_enqueued"] = True + result_entry["continuation_reason"] = queue_reason + result_entry["continuation_cstart"] = queue_cstart + return + if await queue_service.clear_job_for_scholar(db_session, user_id=user_id, scholar_profile_id=scholar.id): + result_entry["continuation_cleared"] = True + + +def resolve_continuation_queue_target( + *, + outcome: str, + state: str, + pagination_truncated_reason: str | None, + continuation_cstart: int | None, + fallback_cstart: int, +) -> tuple[str | None, int]: + if outcome == "partial": + reason = (pagination_truncated_reason or "").strip() + if reason in RESUMABLE_PARTIAL_REASONS or reason.startswith(RESUMABLE_PARTIAL_REASON_PREFIXES): + return reason, queue_service.normalize_cstart( + continuation_cstart if continuation_cstart is not None else fallback_cstart + ) + return None, queue_service.normalize_cstart(fallback_cstart) + + if outcome == "failed" and state == ParseState.NETWORK_ERROR.value: + return "network_error_retry", queue_service.normalize_cstart( + continuation_cstart if continuation_cstart is not None else fallback_cstart + ) + + return None, queue_service.normalize_cstart(fallback_cstart) + + +async def process_scholar( + db_session: AsyncSession, + *, + pagination: PaginationEngine, + run: CrawlRun, + scholar: ScholarProfile, + user_id: int, + request_delay_seconds: int, + network_error_retries: int, + retry_backoff_seconds: float, + rate_limit_retries: int, + rate_limit_backoff_seconds: float, + max_pages_per_scholar: int, + page_size: int, + start_cstart: int, + auto_queue_continuations: bool, + queue_delay_seconds: int, +) -> ScholarProcessingOutcome: + try: + run_dt, paged_parse_result, result_entry = await _fetch_and_prepare_scholar_result( + db_session, + pagination=pagination, + run=run, + scholar=scholar, + user_id=user_id, + start_cstart=start_cstart, + request_delay_seconds=request_delay_seconds, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + rate_limit_retries=rate_limit_retries, + rate_limit_backoff_seconds=rate_limit_backoff_seconds, + max_pages_per_scholar=max_pages_per_scholar, + page_size=page_size, + ) + outcome = await _resolve_scholar_outcome( + db_session, + run=run, + scholar=scholar, + run_dt=run_dt, + paged_parse_result=paged_parse_result, + result_entry=result_entry, + ) + await sync_continuation_queue( + db_session, + user_id=user_id, + scholar=scholar, + run=run, + start_cstart=start_cstart, + result_entry=outcome.result_entry, + paged_parse_result=paged_parse_result, + auto_queue_continuations=auto_queue_continuations, + queue_delay_seconds=queue_delay_seconds, + ) + return outcome + except (OperationalError, InterfaceError): + raise + except Exception as exc: + return unexpected_scholar_exception_outcome( + run=run, + scholar=scholar, + start_cstart=start_cstart, + exc=exc, + ) + + +async def _fetch_and_prepare_scholar_result( + db_session: AsyncSession, + *, + pagination: PaginationEngine, + run: CrawlRun, + scholar: ScholarProfile, + user_id: int, + start_cstart: int, + request_delay_seconds: int, + network_error_retries: int, + retry_backoff_seconds: float, + rate_limit_retries: int, + rate_limit_backoff_seconds: float, + max_pages_per_scholar: int, + page_size: int, +) -> tuple[datetime, PagedParseResult, dict[str, Any]]: + run_dt = datetime.now(UTC) + paged_parse_result = await pagination.fetch_and_parse_all_pages( + scholar=scholar, + run=run, + db_session=db_session, + start_cstart=start_cstart, + request_delay_seconds=request_delay_seconds, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + rate_limit_retries=rate_limit_retries, + rate_limit_backoff_seconds=rate_limit_backoff_seconds, + max_pages=max_pages_per_scholar, + page_size=page_size, + previous_initial_page_fingerprint_sha256=scholar.last_initial_page_fingerprint_sha256, + upsert_publications_fn=upsert_profile_publications, + ) + assert_valid_paged_parse_result(scholar_id=scholar.scholar_id, paged_parse_result=paged_parse_result) + apply_first_page_profile_metadata(scholar=scholar, paged_parse_result=paged_parse_result, run_dt=run_dt) + parsed_page = paged_parse_result.parsed_page + structured_log( + logger, + "info", + "ingestion.scholar_parsed", + user_id=user_id, + crawl_run_id=run.id, + scholar_profile_id=scholar.id, + scholar_id=scholar.scholar_id, + state=parsed_page.state.value, + publication_count=len(paged_parse_result.publications), + has_show_more_button=parsed_page.has_show_more_button, + pages_fetched=paged_parse_result.pages_fetched, + pages_attempted=paged_parse_result.pages_attempted, + has_more_remaining=paged_parse_result.has_more_remaining, + pagination_truncated_reason=paged_parse_result.pagination_truncated_reason, + warning_count=len(parsed_page.warnings), + skipped_no_change=paged_parse_result.skipped_no_change, + ) + result_entry = build_result_entry( + scholar=scholar, + start_cstart=start_cstart, + paged_parse_result=paged_parse_result, + ) + return run_dt, paged_parse_result, result_entry + + +async def _resolve_scholar_outcome( + db_session: AsyncSession, + *, + run: CrawlRun, + scholar: ScholarProfile, + run_dt: datetime, + paged_parse_result: PagedParseResult, + result_entry: dict[str, Any], +) -> ScholarProcessingOutcome: + if paged_parse_result.skipped_no_change: + return skipped_no_change_outcome( + scholar=scholar, + run_dt=run_dt, + paged_parse_result=paged_parse_result, + result_entry=result_entry, + ) + return await upsert_publications_outcome( + db_session, + run=run, + scholar=scholar, + run_dt=run_dt, + paged_parse_result=paged_parse_result, + result_entry=result_entry, + ) + + +def _is_hard_challenge_outcome(outcome: ScholarProcessingOutcome) -> bool: + entry = outcome.result_entry + return str(entry.get("state", "")) == ParseState.BLOCKED_OR_CAPTCHA.value and is_hard_challenge_reason( + str(entry.get("state_reason", "")) + ) + + +async def _run_first_pass( + db_session: AsyncSession, + *, + scholars: list[ScholarProfile], + pagination: PaginationEngine, + run: CrawlRun, + user_id: int, + start_cstart_map: dict[int, int], + scholar_kwargs: dict[str, Any], + request_delay_seconds: int, + queue_delay_seconds: int, + progress: RunProgress, +) -> dict[int, int]: + first_pass_cstarts: dict[int, int] = {} + for index, scholar in enumerate(scholars): + await db_session.refresh(run) + if run.status == RunStatus.CANCELED: + structured_log(logger, "info", "ingestion.run_canceled", run_id=run.id, user_id=user_id) + return first_pass_cstarts + if index > 0 and request_delay_seconds > 0: + jitter = random.uniform(0.0, min(float(request_delay_seconds), 2.0)) + await asyncio.sleep(float(request_delay_seconds) + jitter) + start_cstart = int(start_cstart_map.get(int(scholar.id), 0)) + outcome = await process_scholar( + db_session, + pagination=pagination, + run=run, + scholar=scholar, + user_id=user_id, + start_cstart=start_cstart, + max_pages_per_scholar=1, + auto_queue_continuations=False, + queue_delay_seconds=queue_delay_seconds, + **scholar_kwargs, + ) + apply_outcome_to_progress(progress=progress, outcome=outcome) + if _is_hard_challenge_outcome(outcome): + structured_log( + logger, + "warning", + "ingestion.run_aborted_hard_challenge", + run_id=run.id, + user_id=user_id, + scholar_id=scholar.scholar_id, + state_reason=outcome.result_entry.get("state_reason"), + scholars_remaining=len(scholars) - index - 1, + ) + return first_pass_cstarts + resume_cstart = outcome.result_entry.get("continuation_cstart") + if resume_cstart is not None and int(resume_cstart) > start_cstart: + first_pass_cstarts[int(scholar.id)] = int(resume_cstart) + return first_pass_cstarts + + +async def _run_depth_pass( + db_session: AsyncSession, + *, + scholars: list[ScholarProfile], + first_pass_cstarts: dict[int, int], + pagination: PaginationEngine, + run: CrawlRun, + user_id: int, + scholar_kwargs: dict[str, Any], + request_delay_seconds: int, + remaining_max: int, + auto_queue_continuations: bool, + queue_delay_seconds: int, + progress: RunProgress, +) -> None: + for index, scholar in enumerate(scholars): + resume_cstart = first_pass_cstarts.get(int(scholar.id)) + if resume_cstart is None: + continue + await db_session.refresh(run) + if run.status == RunStatus.CANCELED: + structured_log(logger, "info", "ingestion.run_canceled", run_id=run.id, user_id=user_id) + break + if index > 0 and request_delay_seconds > 0: + jitter = random.uniform(0.0, min(float(request_delay_seconds), 2.0)) + await asyncio.sleep(float(request_delay_seconds) + jitter) + outcome = await process_scholar( + db_session, + pagination=pagination, + run=run, + scholar=scholar, + user_id=user_id, + start_cstart=resume_cstart, + max_pages_per_scholar=remaining_max, + auto_queue_continuations=auto_queue_continuations, + queue_delay_seconds=queue_delay_seconds, + **scholar_kwargs, + ) + apply_outcome_to_progress(progress=progress, outcome=outcome) + if _is_hard_challenge_outcome(outcome): + structured_log( + logger, + "warning", + "ingestion.run_aborted_hard_challenge", + run_id=run.id, + user_id=user_id, + scholar_id=scholar.scholar_id, + state_reason=outcome.result_entry.get("state_reason"), + scholars_remaining=len(scholars) - index - 1, + ) + break + + +async def run_scholar_iteration( + db_session: AsyncSession, + *, + pagination: PaginationEngine, + run: CrawlRun, + scholars: list[ScholarProfile], + user_id: int, + start_cstart_map: dict[int, int], + request_delay_seconds: int, + network_error_retries: int, + retry_backoff_seconds: float, + rate_limit_retries: int, + rate_limit_backoff_seconds: float, + max_pages_per_scholar: int, + page_size: int, + auto_queue_continuations: bool, + queue_delay_seconds: int, +) -> RunProgress: + progress = RunProgress() + scholar_kwargs: dict[str, Any] = { + "request_delay_seconds": request_delay_seconds, + "network_error_retries": network_error_retries, + "retry_backoff_seconds": retry_backoff_seconds, + "rate_limit_retries": rate_limit_retries, + "rate_limit_backoff_seconds": rate_limit_backoff_seconds, + "page_size": page_size, + } + first_pass_cstarts = await _run_first_pass( + db_session, + scholars=scholars, + pagination=pagination, + run=run, + user_id=user_id, + start_cstart_map=start_cstart_map, + scholar_kwargs=scholar_kwargs, + request_delay_seconds=request_delay_seconds, + queue_delay_seconds=queue_delay_seconds, + progress=progress, + ) + remaining_max = max(max_pages_per_scholar - 1, 0) + if remaining_max <= 0: + return progress + await _run_depth_pass( + db_session, + scholars=scholars, + first_pass_cstarts=first_pass_cstarts, + pagination=pagination, + run=run, + user_id=user_id, + scholar_kwargs=scholar_kwargs, + request_delay_seconds=request_delay_seconds, + remaining_max=remaining_max, + auto_queue_continuations=auto_queue_continuations, + queue_delay_seconds=queue_delay_seconds, + progress=progress, + ) + return progress diff --git a/app/services/domains/ingestion/types.py b/app/services/ingestion/types.py similarity index 92% rename from app/services/domains/ingestion/types.py rename to app/services/ingestion/types.py index f6de578..1f6aae8 100644 --- a/app/services/domains/ingestion/types.py +++ b/app/services/ingestion/types.py @@ -4,8 +4,8 @@ from dataclasses import dataclass, field from typing import Any from app.db.models import RunStatus -from app.services.domains.scholar.parser import ParsedProfilePage, PublicationCandidate -from app.services.domains.scholar.source import FetchResult +from app.services.scholar.parser import ParsedProfilePage, PublicationCandidate +from app.services.scholar.source import FetchResult @dataclass(frozen=True) @@ -35,6 +35,7 @@ class PagedParseResult: pagination_truncated_reason: str | None continuation_cstart: int | None skipped_no_change: bool + discovered_publication_count: int @dataclass @@ -88,6 +89,7 @@ class PagedLoopState: has_more_remaining: bool = False pagination_truncated_reason: str | None = None continuation_cstart: int | None = None + discovered_publication_count: int = 0 class RunAlreadyInProgressError(RuntimeError): diff --git a/app/services/openalex/__init__.py b/app/services/openalex/__init__.py new file mode 100644 index 0000000..a51555a --- /dev/null +++ b/app/services/openalex/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/app/services/openalex/client.py b/app/services/openalex/client.py new file mode 100644 index 0000000..13db8ab --- /dev/null +++ b/app/services/openalex/client.py @@ -0,0 +1,166 @@ +import logging + +import httpx +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from app.logging_utils import structured_log +from app.services.openalex.types import OpenAlexWork + +logger = logging.getLogger(__name__) + +OPENALEX_BASE_URL = "https://api.openalex.org" + + +class OpenAlexClientError(Exception): + pass + + +class OpenAlexRateLimitError(OpenAlexClientError): + """Transient rate limit (too many requests per second).""" + + pass + + +class OpenAlexBudgetExhaustedError(OpenAlexClientError): + """Daily API budget exhausted — retrying is futile until midnight UTC.""" + + pass + + +class OpenAlexClient: + def __init__( + self, + api_key: str | None = None, + mailto: str | None = None, + timeout: float = 10.0, + ) -> None: + self.api_key = api_key + self.mailto = mailto + self.timeout = timeout + + @property + def _base_params(self) -> dict[str, str]: + params = {} + if self.mailto: + params["mailto"] = self.mailto + if self.api_key: + params["api_key"] = self.api_key + return params + + @retry( + retry=retry_if_exception_type((httpx.NetworkError, httpx.TimeoutException)), + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=2, max=10), + reraise=True, + ) + async def get_work_by_doi(self, doi: str) -> OpenAlexWork | None: + """Fetch a single work by DOI directly.""" + clean_doi = doi.replace("https://doi.org/", "") + if not clean_doi: + return None + + url = f"{OPENALEX_BASE_URL}/works/{clean_doi}" + + headers = {} + if self.mailto: + headers["User-Agent"] = f"scholar-scraper/1.0 (mailto:{self.mailto})" + else: + headers["User-Agent"] = "scholar-scraper/1.0" + + async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, headers=headers) as client: + response = await client.get(url, params=self._base_params) + + if response.status_code == 404: + return None + if response.status_code == 429: + remaining = response.headers.get("X-RateLimit-Remaining-USD", "") + if remaining == "0" or remaining.startswith("-"): + raise OpenAlexBudgetExhaustedError("Daily API budget exhausted; retrying won't help until midnight UTC") + raise OpenAlexRateLimitError("Rate limit exceeded fetching OpenAlex work by DOI") + if response.status_code >= 400: + structured_log( + logger, + "warning", + "openalex.api_error", + status_code=response.status_code, + response_preview=response.text[:500], + ) + raise OpenAlexClientError(f"API Error {response.status_code}") + + data = response.json() + return OpenAlexWork.from_api_dict(data) + + @retry( + retry=retry_if_exception_type((httpx.NetworkError, httpx.TimeoutException)), + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=2, max=10), + reraise=True, + ) + async def get_works_by_filter( + self, + filters: dict[str, str], + limit: int = 50, + ) -> list[OpenAlexWork]: + """ + Fetch works using the ?filter= query parameter. + Supports fetching multiple records by joining filters with | (OR logic). + """ + if not filters: + return [] + + # Example: {"doi": "10.foo|10.bar", "title.search": "query"} + filter_str = ",".join(f"{k}:{v}" for k, v in filters.items()) + + params = self._base_params.copy() + params["filter"] = filter_str + params["per-page"] = str(limit) + + url = f"{OPENALEX_BASE_URL}/works" + + headers = {} + if self.mailto: + headers["User-Agent"] = f"scholar-scraper/1.0 (mailto:{self.mailto})" + else: + headers["User-Agent"] = "scholar-scraper/1.0" + + async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, headers=headers) as client: + response = await client.get(url, params=params) + + if response.status_code == 429: + remaining = response.headers.get("X-RateLimit-Remaining-USD", "") + if remaining == "0" or remaining.startswith("-"): + raise OpenAlexBudgetExhaustedError("Daily API budget exhausted; retrying won't help until midnight UTC") + raise OpenAlexRateLimitError("Rate limit exceeded fetching OpenAlex works list") + if response.status_code >= 400: + structured_log( + logger, + "warning", + "openalex.api_error_with_filters", + filters=filters, + status_code=response.status_code, + response_preview=response.text[:500], + ) + raise OpenAlexClientError(f"API Error {response.status_code}") + + data = response.json() + results = data.get("results") or [] + + parsed_works = [] + for raw_work in results: + try: + parsed_works.append(OpenAlexWork.from_api_dict(raw_work)) + except Exception as exc: + structured_log( + logger, + "warning", + "openalex.parse_failed", + error=str(exc), + ) + continue + + return parsed_works diff --git a/app/services/openalex/matching.py b/app/services/openalex/matching.py new file mode 100644 index 0000000..34e1927 --- /dev/null +++ b/app/services/openalex/matching.py @@ -0,0 +1,110 @@ +import logging +import re + +from rapidfuzz import fuzz + +from app.services.openalex.types import OpenAlexWork + +logger = logging.getLogger(__name__) + +# A minimum similarity score out of 100 for a title to be considered a match candidate. +TITLE_MATCH_THRESHOLD = 90.0 +# The margin within the top score where a secondary tiebreaker (author/year) is necessary. +TIEBREAKER_MARGIN = 5.0 + + +def _clean_string(s: str | None) -> str: + if not s: + return "" + # Strip non-alphanumeric (keep spaces), lowercase, and collapse whitespace + cleaned = re.sub(r"[^a-z0-9\s]", " ", s.lower()) + return " ".join(cleaned.split()) + + +def _author_overlap_score(target_authors: str | None, candidate_authors: list[str]) -> bool: + if not target_authors or not candidate_authors: + return False + + target_clean = _clean_string(target_authors) + if not target_clean: + return False + + for candidate in candidate_authors: + cand_clean = _clean_string(candidate) + if cand_clean and (cand_clean in target_clean or target_clean in cand_clean): + return True + # Alternatively check rapidfuzz token_set_ratio + if cand_clean and fuzz.token_set_ratio(target_clean, cand_clean) > 80: + return True + + return False + + +def find_best_match( + target_title: str, + target_year: int | None, + target_authors: str | None, + candidates: list[OpenAlexWork], +) -> OpenAlexWork | None: + """ + Finds the best matching OpenAlexWork from a list of candidates, prioritizing title similarity (>90%) + with year and author overlap as tiebreakers for close candidates. + """ + if not target_title or not candidates: + return None + + clean_target = _clean_string(target_title) + if not clean_target: + return None + + scored_candidates: list[tuple[float, OpenAlexWork]] = [] + + for cand in candidates: + if not cand.title: + continue + + clean_cand = _clean_string(cand.title) + + # Primary sort: string similarity ratio + score = fuzz.ratio(clean_target, clean_cand) + + if score >= TITLE_MATCH_THRESHOLD: + scored_candidates.append((score, cand)) + + if not scored_candidates: + return None + + # Sort descending by score + scored_candidates.sort(key=lambda x: x[0], reverse=True) + + best_score = scored_candidates[0][0] + + # Extract all candidates within the tiebreaker margin + top_scored_candidates = [ + (score, cand) for score, cand in scored_candidates if best_score - score <= TIEBREAKER_MARGIN + ] + + if len(top_scored_candidates) == 1: + return top_scored_candidates[0][1] + + # We have a tie or near-tie. Use year and author overlap to break the tie. + # Score candidates: +1 for year match (within 1 year), +1 for author overlap + tiebreaker_scores: list[tuple[int, float, OpenAlexWork]] = [] + + for original_score, cand in top_scored_candidates: + tb_score = 0 + if ( + target_year is not None + and cand.publication_year is not None + and abs(target_year - cand.publication_year) <= 1 + ): + tb_score += 1 + + candidate_author_names = [a.display_name for a in cand.authors if a.display_name] + if _author_overlap_score(target_authors, candidate_author_names): + tb_score += 1 + + tiebreaker_scores.append((tb_score, original_score, cand)) + + tiebreaker_scores.sort(key=lambda x: (x[0], x[1]), reverse=True) + return tiebreaker_scores[0][2] diff --git a/app/services/openalex/types.py b/app/services/openalex/types.py new file mode 100644 index 0000000..8abf375 --- /dev/null +++ b/app/services/openalex/types.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class OpenAlexAuthor: + openalex_id: str | None + display_name: str | None + + +@dataclass(frozen=True) +class OpenAlexWork: + openalex_id: str + doi: str | None + pmid: str | None + pmcid: str | None + title: str | None + publication_year: int | None + cited_by_count: int + is_oa: bool + oa_url: str | None + authors: list[OpenAlexAuthor] = field(default_factory=list) + raw_data: Mapping[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_api_dict(cls, data: Mapping[str, Any]) -> OpenAlexWork: + ids = data.get("ids") or {} + + # Extract DOI without the https://doi.org/ prefix + doi = ids.get("doi") + if doi and doi.startswith("https://doi.org/"): + doi = doi[16:] + + # Extract PMID without the url prefix + pmid = ids.get("pmid") + if pmid and pmid.startswith("https://pubmed.ncbi.nlm.nih.gov/"): + pmid = pmid[32:] + + # Extract PMCID without the url prefix + pmcid = ids.get("pmcid") + if pmcid and pmcid.startswith("https://www.ncbi.nlm.nih.gov/pmc/articles/"): + pmcid = pmcid[42:] + + open_access = data.get("open_access") or {} + + authors = [] + for authorship in data.get("authorships") or []: + author_data = authorship.get("author") or {} + authors.append( + OpenAlexAuthor( + openalex_id=author_data.get("id"), + display_name=author_data.get("display_name"), + ) + ) + + return cls( + openalex_id=data.get("id", ""), + doi=doi, + pmid=pmid, + pmcid=pmcid, + title=data.get("title"), + publication_year=data.get("publication_year"), + cited_by_count=data.get("cited_by_count", 0), + is_oa=bool(open_access.get("is_oa")), + oa_url=open_access.get("oa_url"), + authors=authors, + raw_data=dict(data), + ) diff --git a/app/services/portability/__init__.py b/app/services/portability/__init__.py new file mode 100644 index 0000000..9f63c8f --- /dev/null +++ b/app/services/portability/__init__.py @@ -0,0 +1,21 @@ +from app.services.portability.application import ( + EXPORT_SCHEMA_VERSION as EXPORT_SCHEMA_VERSION, +) +from app.services.portability.application import ( + MAX_IMPORT_PUBLICATIONS as MAX_IMPORT_PUBLICATIONS, +) +from app.services.portability.application import ( + MAX_IMPORT_SCHOLARS as MAX_IMPORT_SCHOLARS, +) +from app.services.portability.application import ( + ImportedPublicationInput as ImportedPublicationInput, +) +from app.services.portability.application import ( + ImportExportError as ImportExportError, +) +from app.services.portability.application import ( + export_user_data as export_user_data, +) +from app.services.portability.application import ( + import_user_data as import_user_data, +) diff --git a/app/services/domains/portability/application.py b/app/services/portability/application.py similarity index 78% rename from app/services/domains/portability/application.py rename to app/services/portability/application.py index 7e0b56e..c696f4d 100644 --- a/app/services/domains/portability/application.py +++ b/app/services/portability/application.py @@ -5,20 +5,20 @@ from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Publication -from app.services.domains.portability.constants import ( +from app.services.portability.constants import ( EXPORT_SCHEMA_VERSION, MAX_IMPORT_PUBLICATIONS, MAX_IMPORT_SCHOLARS, ) -from app.services.domains.portability.exporting import export_user_data -from app.services.domains.portability.normalize import _validate_import_sizes -from app.services.domains.portability.publication_import import ( +from app.services.portability.exporting import export_user_data +from app.services.portability.normalize import _validate_import_sizes +from app.services.portability.publication_import import ( _build_imported_publication_input, _initialize_import_counters, _upsert_imported_publication, ) -from app.services.domains.portability.scholar_import import _upsert_imported_scholars -from app.services.domains.portability.types import ImportExportError, ImportedPublicationInput +from app.services.portability.scholar_import import _upsert_imported_scholars +from app.services.portability.types import ImportedPublicationInput, ImportExportError async def import_user_data( @@ -58,8 +58,8 @@ async def import_user_data( __all__ = [ "EXPORT_SCHEMA_VERSION", - "MAX_IMPORT_SCHOLARS", "MAX_IMPORT_PUBLICATIONS", + "MAX_IMPORT_SCHOLARS", "ImportExportError", "ImportedPublicationInput", "export_user_data", diff --git a/app/services/domains/portability/constants.py b/app/services/portability/constants.py similarity index 100% rename from app/services/domains/portability/constants.py rename to app/services/portability/constants.py diff --git a/app/services/domains/portability/exporting.py b/app/services/portability/exporting.py similarity index 84% rename from app/services/domains/portability/exporting.py rename to app/services/portability/exporting.py index ef43a89..0589498 100644 --- a/app/services/domains/portability/exporting.py +++ b/app/services/portability/exporting.py @@ -1,17 +1,17 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Publication, ScholarProfile, ScholarPublication -from app.services.domains.portability.constants import EXPORT_SCHEMA_VERSION +from app.services.portability.constants import EXPORT_SCHEMA_VERSION def _exported_at_iso() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + return datetime.now(UTC).replace(microsecond=0).isoformat() def _serialize_export_scholar(profile: ScholarProfile) -> dict[str, Any]: @@ -23,7 +23,7 @@ def _serialize_export_scholar(profile: ScholarProfile) -> dict[str, Any]: } -def _serialize_export_publication(row: tuple[Any, ...]) -> dict[str, Any]: +def _serialize_export_publication(row: Any) -> dict[str, Any]: ( scholar_id, cluster_id, @@ -34,7 +34,6 @@ def _serialize_export_publication(row: tuple[Any, ...]) -> dict[str, Any]: author_text, venue_text, pub_url, - doi, pdf_url, is_read, ) = row @@ -48,7 +47,6 @@ def _serialize_export_publication(row: tuple[Any, ...]) -> dict[str, Any]: "author_text": author_text, "venue_text": venue_text, "pub_url": pub_url, - "doi": doi, "pdf_url": pdf_url, "is_read": bool(is_read), } @@ -60,9 +58,7 @@ async def export_user_data( user_id: int, ) -> dict[str, Any]: scholars_result = await db_session.execute( - select(ScholarProfile) - .where(ScholarProfile.user_id == user_id) - .order_by(ScholarProfile.id.asc()) + select(ScholarProfile).where(ScholarProfile.user_id == user_id).order_by(ScholarProfile.id.asc()) ) publication_result = await db_session.execute( select( @@ -75,7 +71,6 @@ async def export_user_data( Publication.author_text, Publication.venue_text, Publication.pub_url, - Publication.doi, Publication.pdf_url, ScholarPublication.is_read, ) diff --git a/app/services/domains/portability/normalize.py b/app/services/portability/normalize.py similarity index 88% rename from app/services/domains/portability/normalize.py rename to app/services/portability/normalize.py index 60dd619..9353d2a 100644 --- a/app/services/domains/portability/normalize.py +++ b/app/services/portability/normalize.py @@ -3,14 +3,14 @@ from __future__ import annotations import hashlib from typing import Any -from app.services.domains.ingestion.application import normalize_title -from app.services.domains.portability.constants import ( +from app.services.ingestion.fingerprints import normalize_title +from app.services.portability.constants import ( MAX_IMPORT_PUBLICATIONS, MAX_IMPORT_SCHOLARS, SHA256_RE, WORD_RE, ) -from app.services.domains.portability.types import ImportExportError +from app.services.portability.types import ImportExportError def _normalize_optional_text(value: Any) -> str | None: @@ -104,6 +104,4 @@ def _validate_import_sizes( if len(scholars) > MAX_IMPORT_SCHOLARS: raise ImportExportError(f"Import exceeds max scholars ({MAX_IMPORT_SCHOLARS}).") if len(publications) > MAX_IMPORT_PUBLICATIONS: - raise ImportExportError( - f"Import exceeds max publications ({MAX_IMPORT_PUBLICATIONS})." - ) + raise ImportExportError(f"Import exceeds max publications ({MAX_IMPORT_PUBLICATIONS}).") diff --git a/app/services/domains/portability/publication_import.py b/app/services/portability/publication_import.py similarity index 93% rename from app/services/domains/portability/publication_import.py rename to app/services/portability/publication_import.py index daa1d5d..e2eb96a 100644 --- a/app/services/domains/portability/publication_import.py +++ b/app/services/portability/publication_import.py @@ -6,15 +6,15 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Publication, ScholarProfile, ScholarPublication -from app.services.domains.ingestion.application import build_publication_url, normalize_title -from app.services.domains.doi.normalize import normalize_doi -from app.services.domains.portability.normalize import ( +from app.services.doi.normalize import normalize_doi +from app.services.ingestion.fingerprints import build_publication_url, normalize_title +from app.services.portability.normalize import ( _normalize_citation_count, _normalize_optional_text, _normalize_optional_year, _resolve_fingerprint, ) -from app.services.domains.portability.types import ImportedPublicationInput +from app.services.portability.types import ImportedPublicationInput async def _find_publication_by_cluster( @@ -22,9 +22,7 @@ async def _find_publication_by_cluster( *, cluster_id: str, ) -> Publication | None: - result = await db_session.execute( - select(Publication).where(Publication.cluster_id == cluster_id) - ) + result = await db_session.execute(select(Publication).where(Publication.cluster_id == cluster_id)) return result.scalar_one_or_none() @@ -33,9 +31,7 @@ async def _find_publication_by_fingerprint( *, fingerprint_sha256: str, ) -> Publication | None: - result = await db_session.execute( - select(Publication).where(Publication.fingerprint_sha256 == fingerprint_sha256) - ) + result = await db_session.execute(select(Publication).where(Publication.fingerprint_sha256 == fingerprint_sha256)) return result.scalar_one_or_none() @@ -71,7 +67,6 @@ def _apply_imported_publication_values( author_text: str | None, venue_text: str | None, pub_url: str | None, - doi: str | None, pdf_url: str | None, cluster_id: str | None, ) -> bool: @@ -98,9 +93,6 @@ def _apply_imported_publication_values( if pub_url and publication.pub_url != pub_url: publication.pub_url = pub_url updated = True - if doi and publication.doi != doi: - publication.doi = doi - updated = True if pdf_url and publication.pdf_url != pdf_url: publication.pdf_url = pdf_url updated = True @@ -117,7 +109,6 @@ def _new_publication( author_text: str | None, venue_text: str | None, pub_url: str | None, - doi: str | None, pdf_url: str | None, ) -> Publication: return Publication( @@ -130,7 +121,6 @@ def _new_publication( author_text=author_text, venue_text=venue_text, pub_url=pub_url, - doi=doi, pdf_url=pdf_url, ) @@ -285,7 +275,6 @@ async def _create_import_publication( author_text=payload.author_text, venue_text=payload.venue_text, pub_url=payload.pub_url, - doi=payload.doi, pdf_url=payload.pdf_url, ) db_session.add(publication) @@ -306,7 +295,6 @@ def _update_import_publication( author_text=payload.author_text, venue_text=payload.venue_text, pub_url=payload.pub_url, - doi=payload.doi, pdf_url=payload.pdf_url, cluster_id=payload.cluster_id, ) diff --git a/app/services/domains/portability/scholar_import.py b/app/services/portability/scholar_import.py similarity index 90% rename from app/services/domains/portability/scholar_import.py rename to app/services/portability/scholar_import.py index 74e6ec1..274e7fd 100644 --- a/app/services/domains/portability/scholar_import.py +++ b/app/services/portability/scholar_import.py @@ -6,8 +6,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import ScholarProfile -from app.services.domains.portability.normalize import _normalize_optional_text -from app.services.domains.scholars import application as scholar_service +from app.services.portability.normalize import _normalize_optional_text +from app.services.scholars import application as scholar_service async def _load_user_scholar_map( @@ -15,9 +15,7 @@ async def _load_user_scholar_map( *, user_id: int, ) -> dict[str, ScholarProfile]: - result = await db_session.execute( - select(ScholarProfile).where(ScholarProfile.user_id == user_id) - ) + result = await db_session.execute(select(ScholarProfile).where(ScholarProfile.user_id == user_id)) profiles = list(result.scalars().all()) return {profile.scholar_id: profile for profile in profiles} @@ -70,9 +68,7 @@ async def _upsert_imported_scholars( for item in scholars: try: scholar_id = scholar_service.validate_scholar_id(str(item["scholar_id"])) - display_name = scholar_service.normalize_display_name( - str(item.get("display_name") or "") - ) + display_name = scholar_service.normalize_display_name(str(item.get("display_name") or "")) override_url = scholar_service.normalize_profile_image_url( _normalize_optional_text(item.get("profile_image_override_url")) ) diff --git a/app/services/domains/portability/types.py b/app/services/portability/types.py similarity index 100% rename from app/services/domains/portability/types.py rename to app/services/portability/types.py diff --git a/app/services/publication_identifiers/__init__.py b/app/services/publication_identifiers/__init__.py new file mode 100644 index 0000000..c64a01f --- /dev/null +++ b/app/services/publication_identifiers/__init__.py @@ -0,0 +1,19 @@ +from app.services.publication_identifiers.application import ( + DisplayIdentifier, + derive_display_identifier_from_values, + display_identifier_for_publication_id, + overlay_pdf_queue_items_with_display_identifiers, + overlay_publication_items_with_display_identifiers, + sync_identifiers_for_publication_fields, + sync_identifiers_for_publication_resolution, +) + +__all__ = [ + "DisplayIdentifier", + "derive_display_identifier_from_values", + "display_identifier_for_publication_id", + "overlay_pdf_queue_items_with_display_identifiers", + "overlay_publication_items_with_display_identifiers", + "sync_identifiers_for_publication_fields", + "sync_identifiers_for_publication_resolution", +] diff --git a/app/services/publication_identifiers/application.py b/app/services/publication_identifiers/application.py new file mode 100644 index 0000000..560211e --- /dev/null +++ b/app/services/publication_identifiers/application.py @@ -0,0 +1,525 @@ +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Publication, PublicationIdentifier +from app.services.arxiv.guards import arxiv_skip_reason_for_item +from app.services.doi.normalize import normalize_doi +from app.services.publication_identifiers.normalize import ( + normalize_arxiv_id, + normalize_pmcid, + normalize_pmid, +) +from app.services.publication_identifiers.types import ( + DisplayIdentifier, + IdentifierCandidate, + IdentifierKind, +) + +if TYPE_CHECKING: + from app.services.publications.pdf_queue_queries import PdfQueueListItem + from app.services.publications.types import PublicationListItem, UnreadPublicationItem + +CONFIDENCE_HIGH = 0.98 +CONFIDENCE_MEDIUM = 0.9 +CONFIDENCE_LOW = 0.6 +CONFIDENCE_FALLBACK = 0.4 +PRIORITY_DOI = 400 +PRIORITY_ARXIV = 300 +PRIORITY_PMCID = 200 +PRIORITY_PMID = 100 + + +def derive_display_identifier_from_values( + *, + doi: str | None, + pub_url: str | None = None, + pdf_url: str | None = None, +) -> DisplayIdentifier | None: + candidates = _fallback_candidates_from_values(doi=doi, pub_url=pub_url, pdf_url=pdf_url) + return _best_display_identifier(candidates) + + +def _fallback_candidates_from_values( + *, + doi: str | None, + pub_url: str | None, + pdf_url: str | None, +) -> list[IdentifierCandidate]: + values = [value for value in [pub_url, pdf_url] if value] + candidates = [] + if doi: + normalized_doi = normalize_doi(doi) + if normalized_doi: + candidates.append( + _candidate(IdentifierKind.DOI, doi, normalized_doi, "legacy_doi", CONFIDENCE_HIGH, pub_url) + ) + candidates.extend(_url_identifier_candidates(values=values, source="legacy_urls")) + return _dedup_candidates(candidates) + + +def _url_identifier_candidates(*, values: list[str], source: str) -> list[IdentifierCandidate]: + candidates: list[IdentifierCandidate] = [] + for value in values: + candidates.extend(_url_candidates_for_value(value=value, source=source)) + return candidates + + +def _url_candidates_for_value(*, value: str, source: str) -> list[IdentifierCandidate]: + candidates: list[IdentifierCandidate] = [] + arxiv = normalize_arxiv_id(value) + if arxiv: + candidates.append(_candidate(IdentifierKind.ARXIV, value, arxiv, source, CONFIDENCE_MEDIUM, value)) + pmcid = normalize_pmcid(value) + if pmcid: + candidates.append(_candidate(IdentifierKind.PMCID, value, pmcid, source, CONFIDENCE_LOW, value)) + pmid = normalize_pmid(value) + if pmid: + candidates.append(_candidate(IdentifierKind.PMID, value, pmid, source, CONFIDENCE_FALLBACK, value)) + return candidates + + +def _candidate( + kind: IdentifierKind, + value_raw: str, + value_normalized: str, + source: str, + confidence_score: float, + evidence_url: str | None, +) -> IdentifierCandidate: + return IdentifierCandidate( + kind=kind, + value_raw=value_raw, + value_normalized=value_normalized, + source=source, + confidence_score=float(confidence_score), + evidence_url=evidence_url, + ) + + +def _dedup_candidates(candidates: list[IdentifierCandidate]) -> list[IdentifierCandidate]: + deduped: dict[tuple[str, str], IdentifierCandidate] = {} + for candidate in candidates: + key = (candidate.kind.value, candidate.value_normalized) + current = deduped.get(key) + if current is None or candidate.confidence_score > current.confidence_score: + deduped[key] = candidate + return list(deduped.values()) + + +async def sync_identifiers_for_publication_fields( + db_session: AsyncSession, + *, + publication: Publication, +) -> None: + candidates = _publication_field_candidates(publication) + await _upsert_publication_candidates(db_session, publication_id=int(publication.id), candidates=candidates) + + +async def discover_and_sync_identifiers_for_publication( + db_session: AsyncSession, + *, + publication: Publication, + scholar_label: str, +) -> None: + await sync_identifiers_for_publication_fields(db_session, publication=publication) + + publication_id = int(publication.id) + if await _has_confident_identifier( + db_session, + publication_id=publication_id, + kind=IdentifierKind.DOI.value, + confidence_floor=0.0, + ): + return + + item = _identifier_lookup_item(publication=publication, scholar_label=scholar_label) + has_strong_doi = await _discover_crossref_doi( + db_session, + publication_id=publication_id, + item=item, + ) + existing_arxiv = await _existing_identifier_by_kind( + db_session, + publication_id=publication_id, + kind=IdentifierKind.ARXIV.value, + ) + skip_reason = arxiv_skip_reason_for_item( + item=item, + has_strong_doi=has_strong_doi, + has_existing_arxiv=existing_arxiv is not None, + ) + if skip_reason is not None: + return + await _discover_arxiv_identifier(db_session, publication_id=publication_id, item=item) + + +def _identifier_lookup_item( + *, + publication: Publication, + scholar_label: str, +) -> UnreadPublicationItem: + from app.services.publications.types import UnreadPublicationItem + + return UnreadPublicationItem( + publication_id=int(publication.id), + scholar_profile_id=0, + scholar_label=scholar_label, + title=str(publication.title_raw or ""), + year=publication.year, + citation_count=publication.citation_count, + venue_text=publication.venue_text, + pub_url=publication.pub_url, + pdf_url=publication.pdf_url, + ) + + +async def _discover_crossref_doi( + db_session: AsyncSession, + *, + publication_id: int, + item: UnreadPublicationItem, +) -> bool: + from app.services.crossref import application as crossref_service + + discovered_doi = await crossref_service.discover_doi_for_publication(item=item) + normalized_doi = normalize_doi(discovered_doi) + if discovered_doi is None or normalized_doi is None: + return False + candidate = _candidate( + IdentifierKind.DOI, + discovered_doi, + normalized_doi, + "crossref_api", + CONFIDENCE_MEDIUM, + None, + ) + await _upsert_publication_candidate( + db_session, + publication_id=publication_id, + candidate=candidate, + ) + return candidate.confidence_score >= CONFIDENCE_MEDIUM + + +async def _discover_arxiv_identifier( + db_session: AsyncSession, + *, + publication_id: int, + item: UnreadPublicationItem, +) -> None: + from app.services.arxiv import application as arxiv_service + + discovered_arxiv = await arxiv_service.discover_arxiv_id_for_publication(item=item) + normalized_arxiv = normalize_arxiv_id(discovered_arxiv) + if discovered_arxiv is None or normalized_arxiv is None: + return + candidate = _candidate( + IdentifierKind.ARXIV, + discovered_arxiv, + normalized_arxiv, + "arxiv_api", + CONFIDENCE_MEDIUM, + None, + ) + await _upsert_publication_candidate( + db_session, + publication_id=publication_id, + candidate=candidate, + ) + + +async def _has_confident_identifier( + db_session: AsyncSession, + *, + publication_id: int, + kind: str, + confidence_floor: float, +) -> bool: + existing = await _existing_identifier_by_kind( + db_session, + publication_id=publication_id, + kind=kind, + ) + if existing is None: + return False + return float(existing.confidence_score) >= float(confidence_floor) + + +def _publication_field_candidates(publication: Publication) -> list[IdentifierCandidate]: + return _fallback_candidates_from_values( + doi=None, + pub_url=publication.pub_url, + pdf_url=publication.pdf_url, + ) + + +async def sync_identifiers_for_publication_resolution( + db_session: AsyncSession, + *, + publication: Publication, + source: str | None, +) -> None: + candidates = _publication_field_candidates(publication) + rewritten = [_candidate_with_source(candidate, source=source) for candidate in candidates] + await _upsert_publication_candidates(db_session, publication_id=int(publication.id), candidates=rewritten) + + +def _candidate_with_source(candidate: IdentifierCandidate, *, source: str | None) -> IdentifierCandidate: + if not source: + return candidate + return IdentifierCandidate( + kind=candidate.kind, + value_raw=candidate.value_raw, + value_normalized=candidate.value_normalized, + source=source, + confidence_score=candidate.confidence_score, + evidence_url=candidate.evidence_url, + ) + + +async def _upsert_publication_candidates( + db_session: AsyncSession, + *, + publication_id: int, + candidates: list[IdentifierCandidate], +) -> None: + for candidate in _dedup_candidates(candidates): + await _upsert_publication_candidate(db_session, publication_id=publication_id, candidate=candidate) + + +async def _upsert_publication_candidate( + db_session: AsyncSession, + *, + publication_id: int, + candidate: IdentifierCandidate, +) -> None: + existing = await _existing_identifier( + db_session, + publication_id=publication_id, + kind=candidate.kind.value, + value_normalized=candidate.value_normalized, + ) + if existing is None: + db_session.add(_new_identifier_row(publication_id=publication_id, candidate=candidate)) + return + _merge_identifier_row(existing, candidate=candidate) + + +async def _existing_identifier( + db_session: AsyncSession, + *, + publication_id: int, + kind: str, + value_normalized: str, +) -> PublicationIdentifier | None: + result = await db_session.execute( + select(PublicationIdentifier).where( + PublicationIdentifier.publication_id == publication_id, + PublicationIdentifier.kind == kind, + PublicationIdentifier.value_normalized == value_normalized, + ) + ) + return result.scalar_one_or_none() + + +async def _existing_identifier_by_kind( + db_session: AsyncSession, + *, + publication_id: int, + kind: str, +) -> PublicationIdentifier | None: + result = await db_session.execute( + select(PublicationIdentifier) + .where( + PublicationIdentifier.publication_id == publication_id, + PublicationIdentifier.kind == kind, + ) + .order_by(PublicationIdentifier.confidence_score.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +def _new_identifier_row( + *, + publication_id: int, + candidate: IdentifierCandidate, +) -> PublicationIdentifier: + return PublicationIdentifier( + publication_id=publication_id, + kind=candidate.kind.value, + value_raw=candidate.value_raw, + value_normalized=candidate.value_normalized, + source=candidate.source, + confidence_score=candidate.confidence_score, + evidence_url=candidate.evidence_url, + ) + + +def _merge_identifier_row(existing: PublicationIdentifier, *, candidate: IdentifierCandidate) -> None: + if candidate.confidence_score >= float(existing.confidence_score): + existing.value_raw = candidate.value_raw + existing.source = candidate.source + existing.confidence_score = candidate.confidence_score + if candidate.evidence_url: + existing.evidence_url = candidate.evidence_url + + +async def overlay_publication_items_with_display_identifiers( + db_session: AsyncSession, + *, + items: list[PublicationListItem], +) -> list[PublicationListItem]: + if not items: + return [] + mapping = await _display_identifier_map(db_session, publication_ids=[item.publication_id for item in items]) + return [_overlay_publication_item(item, mapping.get(item.publication_id)) for item in items] + + +def _overlay_publication_item( + item: PublicationListItem, + display_identifier: DisplayIdentifier | None, +) -> PublicationListItem: + fallback = display_identifier or derive_display_identifier_from_values( + doi=None, pub_url=item.pub_url, pdf_url=item.pdf_url + ) + return replace(item, display_identifier=fallback) + + +async def overlay_pdf_queue_items_with_display_identifiers( + db_session: AsyncSession, + *, + items: list[PdfQueueListItem], +) -> list[PdfQueueListItem]: + if not items: + return [] + mapping = await _display_identifier_map(db_session, publication_ids=[item.publication_id for item in items]) + return [_overlay_queue_item(item, mapping.get(item.publication_id)) for item in items] + + +def _overlay_queue_item( + item: PdfQueueListItem, + display_identifier: DisplayIdentifier | None, +) -> PdfQueueListItem: + fallback = display_identifier or derive_display_identifier_from_values(doi=None, pdf_url=item.pdf_url) + return replace(item, display_identifier=fallback) + + +async def display_identifier_for_publication_id( + db_session: AsyncSession, + *, + publication_id: int, +) -> DisplayIdentifier | None: + normalized_id = int(publication_id) + if normalized_id <= 0: + raise ValueError("publication_id must be positive.") + mapping = await _display_identifier_map(db_session, publication_ids=[normalized_id]) + display = mapping.get(normalized_id) + if display is not None: + return display + publication = await db_session.get(Publication, normalized_id) + if publication is None: + return None + return derive_display_identifier_from_values( + doi=None, + pub_url=publication.pub_url, + pdf_url=publication.pdf_url, + ) + + +async def _display_identifier_map( + db_session: AsyncSession, + *, + publication_ids: list[int], +) -> dict[int, DisplayIdentifier]: + normalized_ids = sorted({int(value) for value in publication_ids if int(value) > 0}) + if not normalized_ids: + return {} + result = await db_session.execute( + select(PublicationIdentifier).where(PublicationIdentifier.publication_id.in_(normalized_ids)) + ) + rows = list(result.scalars().all()) + return _best_display_identifier_map(rows) + + +def _best_display_identifier_map(rows: list[PublicationIdentifier]) -> dict[int, DisplayIdentifier]: + grouped: dict[int, list[IdentifierCandidate]] = {} + for row in rows: + grouped.setdefault(int(row.publication_id), []).append(_candidate_from_row(row)) + return { + publication_id: display + for publication_id, display in ( + (publication_id, _best_display_identifier(candidates)) for publication_id, candidates in grouped.items() + ) + if display is not None + } + + +def _candidate_from_row(row: PublicationIdentifier) -> IdentifierCandidate: + return IdentifierCandidate( + kind=IdentifierKind(str(row.kind)), + value_raw=str(row.value_raw), + value_normalized=str(row.value_normalized), + source=str(row.source), + confidence_score=float(row.confidence_score), + evidence_url=row.evidence_url, + ) + + +def _best_display_identifier(candidates: list[IdentifierCandidate]) -> DisplayIdentifier | None: + if not candidates: + return None + ordered = sorted(candidates, key=_display_sort_key, reverse=True) + return _display_identifier_from_candidate(ordered[0]) + + +def _display_sort_key(candidate: IdentifierCandidate) -> tuple[int, float]: + return (_kind_priority(candidate.kind), float(candidate.confidence_score)) + + +def _kind_priority(kind: IdentifierKind) -> int: + if kind == IdentifierKind.DOI: + return PRIORITY_DOI + if kind == IdentifierKind.ARXIV: + return PRIORITY_ARXIV + if kind == IdentifierKind.PMCID: + return PRIORITY_PMCID + return PRIORITY_PMID + + +def _display_identifier_from_candidate(candidate: IdentifierCandidate) -> DisplayIdentifier: + value = candidate.value_normalized + return DisplayIdentifier( + kind=candidate.kind.value, + value=value, + label=_display_label(candidate.kind, value), + url=_identifier_url(candidate.kind, value), + confidence_score=float(candidate.confidence_score), + ) + + +def _display_label(kind: IdentifierKind, value: str) -> str: + if kind == IdentifierKind.DOI: + return f"DOI: {value}" + if kind == IdentifierKind.ARXIV: + return f"arXiv: {value}" + if kind == IdentifierKind.PMCID: + return f"PMCID: {value}" + return f"PMID: {value}" + + +def _identifier_url(kind: IdentifierKind, value: str) -> str | None: + if kind == IdentifierKind.DOI: + return f"https://doi.org/{value}" + if kind == IdentifierKind.ARXIV: + return f"https://arxiv.org/abs/{value}" + if kind == IdentifierKind.PMCID: + return f"https://pmc.ncbi.nlm.nih.gov/articles/{value}/" + if kind == IdentifierKind.PMID: + return f"https://pubmed.ncbi.nlm.nih.gov/{value}/" + return None diff --git a/app/services/publication_identifiers/normalize.py b/app/services/publication_identifiers/normalize.py new file mode 100644 index 0000000..1308a8d --- /dev/null +++ b/app/services/publication_identifiers/normalize.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import re +from urllib.parse import urlparse + +from app.services.doi.normalize import normalize_doi +from app.services.publication_identifiers.types import IdentifierKind + +ARXIV_ABS_RE = re.compile(r"\barxiv:\s*([a-z-]+/\d{7}|\d{4}\.\d{4,5})(v\d+)?\b", re.I) +ARXIV_RAW_RE = re.compile(r"^([a-z-]+/\d{7}|\d{4}\.\d{4,5})(v\d+)?$", re.I) +ARXIV_PATH_RE = re.compile(r"^/(?:abs|pdf|html|ps|format)/([a-z-]+/\d{7}|\d{4}\.\d{4,5})(v\d+)?(?:\.pdf)?/?$", re.I) +PMCID_RE = re.compile(r"\b(PMC\d+)\b", re.I) +PUBMED_PATH_RE = re.compile(r"^/(\d+)/?$") + + +def normalize_identifier(kind: IdentifierKind, value: str | None) -> str | None: + if kind == IdentifierKind.DOI: + return normalize_doi(value) + if kind == IdentifierKind.ARXIV: + return normalize_arxiv_id(value) + if kind == IdentifierKind.PMCID: + return normalize_pmcid(value) + if kind == IdentifierKind.PMID: + return normalize_pmid(value) + return None + + +def normalize_arxiv_id(value: str | None) -> str | None: + text = (value or "").strip() + if not text: + return None + parsed = urlparse(text) + if parsed.scheme in {"http", "https"} and "arxiv.org" in parsed.netloc.lower(): + return _arxiv_from_path(parsed.path) + raw_match = ARXIV_RAW_RE.match(text) + if raw_match: + version = (raw_match.group(2) or "").lower() + return f"{raw_match.group(1).lower()}{version}" + match = ARXIV_ABS_RE.search(text) + if not match: + return None + version = (match.group(2) or "").lower() + return f"{match.group(1).lower()}{version}" + + +def _arxiv_from_path(path: str) -> str | None: + match = ARXIV_PATH_RE.match(path or "") + if not match: + return None + version = (match.group(2) or "").lower() + return f"{match.group(1).lower()}{version}" + + +def normalize_pmcid(value: str | None) -> str | None: + text = (value or "").strip() + if not text: + return None + parsed = urlparse(text) + if parsed.scheme in {"http", "https"} and "ncbi.nlm.nih.gov" in parsed.netloc.lower(): + return _first_match(PMCID_RE, parsed.path) + return _first_match(PMCID_RE, text) + + +def normalize_pmid(value: str | None) -> str | None: + text = (value or "").strip() + if not text: + return None + parsed = urlparse(text) + if parsed.scheme in {"http", "https"} and "pubmed.ncbi.nlm.nih.gov" in parsed.netloc.lower(): + match = PUBMED_PATH_RE.match(parsed.path or "") + if not match: + return None + return match.group(1) + return None + + +def _first_match(pattern: re.Pattern[str], value: str) -> str | None: + match = pattern.search(value) + if not match: + return None + return match.group(1).upper() diff --git a/app/services/publication_identifiers/types.py b/app/services/publication_identifiers/types.py new file mode 100644 index 0000000..34b20d0 --- /dev/null +++ b/app/services/publication_identifiers/types.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class IdentifierKind(StrEnum): + DOI = "doi" + ARXIV = "arxiv" + PMCID = "pmcid" + PMID = "pmid" + + +@dataclass(frozen=True) +class DisplayIdentifier: + kind: str + value: str + label: str + url: str | None + confidence_score: float + + +@dataclass(frozen=True) +class IdentifierCandidate: + kind: IdentifierKind + value_raw: str + value_normalized: str + source: str + confidence_score: float + evidence_url: str | None diff --git a/app/services/publications/__init__.py b/app/services/publications/__init__.py new file mode 100644 index 0000000..c03a02a --- /dev/null +++ b/app/services/publications/__init__.py @@ -0,0 +1,84 @@ +from app.services.publications.application import ( + MODE_ALL as MODE_ALL, +) +from app.services.publications.application import ( + MODE_LATEST as MODE_LATEST, +) +from app.services.publications.application import ( + MODE_NEW as MODE_NEW, +) +from app.services.publications.application import ( + MODE_UNREAD as MODE_UNREAD, +) +from app.services.publications.application import ( + PublicationListItem as PublicationListItem, +) +from app.services.publications.application import ( + UnreadPublicationItem as UnreadPublicationItem, +) +from app.services.publications.application import ( + count_favorite_for_user as count_favorite_for_user, +) +from app.services.publications.application import ( + count_for_user as count_for_user, +) +from app.services.publications.application import ( + count_latest_for_user as count_latest_for_user, +) +from app.services.publications.application import ( + count_pdf_queue_items as count_pdf_queue_items, +) +from app.services.publications.application import ( + count_unread_for_user as count_unread_for_user, +) +from app.services.publications.application import ( + enqueue_all_missing_pdf_jobs as enqueue_all_missing_pdf_jobs, +) +from app.services.publications.application import ( + enqueue_retry_pdf_job_for_publication_id as enqueue_retry_pdf_job_for_publication_id, +) +from app.services.publications.application import ( + get_latest_run_id_for_user as get_latest_run_id_for_user, +) +from app.services.publications.application import ( + get_publication_item_for_user as get_publication_item_for_user, +) +from app.services.publications.application import ( + hydrate_pdf_enrichment_state as hydrate_pdf_enrichment_state, +) +from app.services.publications.application import ( + list_for_user as list_for_user, +) +from app.services.publications.application import ( + list_pdf_queue_items as list_pdf_queue_items, +) +from app.services.publications.application import ( + list_pdf_queue_page as list_pdf_queue_page, +) +from app.services.publications.application import ( + list_unread_for_user as list_unread_for_user, +) +from app.services.publications.application import ( + mark_all_unread_as_read_for_user as mark_all_unread_as_read_for_user, +) +from app.services.publications.application import ( + mark_selected_as_read_for_user as mark_selected_as_read_for_user, +) +from app.services.publications.application import ( + publications_query as publications_query, +) +from app.services.publications.application import ( + resolve_publication_view_mode as resolve_publication_view_mode, +) +from app.services.publications.application import ( + retry_pdf_for_user as retry_pdf_for_user, +) +from app.services.publications.application import ( + schedule_missing_pdf_enrichment_for_user as schedule_missing_pdf_enrichment_for_user, +) +from app.services.publications.application import ( + schedule_retry_pdf_enrichment_for_row as schedule_retry_pdf_enrichment_for_row, +) +from app.services.publications.application import ( + set_publication_favorite_for_user as set_publication_favorite_for_user, +) diff --git a/app/services/domains/publications/application.py b/app/services/publications/application.py similarity index 73% rename from app/services/domains/publications/application.py rename to app/services/publications/application.py index b1161fb..61cf9ad 100644 --- a/app/services/domains/publications/application.py +++ b/app/services/publications/application.py @@ -1,74 +1,76 @@ from __future__ import annotations -from app.services.domains.publications.counts import ( - count_for_user, +from app.services.publications.counts import ( count_favorite_for_user, + count_for_user, count_latest_for_user, count_unread_for_user, ) -from app.services.domains.publications.listing import ( - list_for_user, - retry_pdf_for_user, - list_unread_for_user, -) -from app.services.domains.publications.enrichment import ( +from app.services.publications.enrichment import ( hydrate_pdf_enrichment_state, schedule_missing_pdf_enrichment_for_user, schedule_retry_pdf_enrichment_for_row, ) -from app.services.domains.publications.modes import ( +from app.services.publications.listing import ( + list_for_user, + list_unread_for_user, + retry_pdf_for_user, +) +from app.services.publications.modes import ( MODE_ALL, MODE_LATEST, MODE_NEW, MODE_UNREAD, resolve_publication_view_mode, ) -from app.services.domains.publications.queries import ( - get_latest_completed_run_id_for_user, +from app.services.publications.pdf_queue import ( + enqueue_all_missing_pdf_jobs, + enqueue_retry_pdf_job_for_publication_id, +) +from app.services.publications.pdf_queue_queries import ( + count_pdf_queue_items, + list_pdf_queue_items, + list_pdf_queue_page, +) +from app.services.publications.queries import ( + get_latest_run_id_for_user, get_publication_item_for_user, publications_query, ) -from app.services.domains.publications.read_state import ( +from app.services.publications.read_state import ( mark_all_unread_as_read_for_user, mark_selected_as_read_for_user, set_publication_favorite_for_user, ) -from app.services.domains.publications.pdf_queue import ( - count_pdf_queue_items, - enqueue_all_missing_pdf_jobs, - enqueue_retry_pdf_job_for_publication_id, - list_pdf_queue_page, - list_pdf_queue_items, -) -from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem +from app.services.publications.types import PublicationListItem, UnreadPublicationItem __all__ = [ "MODE_ALL", - "MODE_UNREAD", "MODE_LATEST", "MODE_NEW", + "MODE_UNREAD", "PublicationListItem", "UnreadPublicationItem", - "resolve_publication_view_mode", - "get_latest_completed_run_id_for_user", - "publications_query", - "get_publication_item_for_user", - "list_for_user", - "list_unread_for_user", - "retry_pdf_for_user", - "hydrate_pdf_enrichment_state", - "schedule_retry_pdf_enrichment_for_row", - "list_pdf_queue_items", - "list_pdf_queue_page", + "count_favorite_for_user", + "count_for_user", + "count_latest_for_user", "count_pdf_queue_items", + "count_unread_for_user", "enqueue_all_missing_pdf_jobs", "enqueue_retry_pdf_job_for_publication_id", - "schedule_missing_pdf_enrichment_for_user", - "count_for_user", - "count_favorite_for_user", - "count_unread_for_user", - "count_latest_for_user", + "get_latest_run_id_for_user", + "get_publication_item_for_user", + "hydrate_pdf_enrichment_state", + "list_for_user", + "list_pdf_queue_items", + "list_pdf_queue_page", + "list_unread_for_user", "mark_all_unread_as_read_for_user", "mark_selected_as_read_for_user", + "publications_query", + "resolve_publication_view_mode", + "retry_pdf_for_user", + "schedule_missing_pdf_enrichment_for_user", + "schedule_retry_pdf_enrichment_for_row", "set_publication_favorite_for_user", ] diff --git a/app/services/domains/publications/counts.py b/app/services/publications/counts.py similarity index 59% rename from app/services/domains/publications/counts.py rename to app/services/publications/counts.py index 8f65172..fc09930 100644 --- a/app/services/domains/publications/counts.py +++ b/app/services/publications/counts.py @@ -1,16 +1,18 @@ from __future__ import annotations -from sqlalchemy import func, select +from datetime import datetime + +from sqlalchemy import distinct, func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import ScholarProfile, ScholarPublication -from app.services.domains.publications.modes import ( +from app.db.models import Publication, ScholarProfile, ScholarPublication +from app.services.publications.modes import ( MODE_ALL, MODE_LATEST, MODE_UNREAD, resolve_publication_view_mode, ) -from app.services.domains.publications.queries import get_latest_completed_run_id_for_user +from app.services.publications.queries import get_latest_run_id_for_user async def count_for_user( @@ -20,19 +22,25 @@ async def count_for_user( mode: str = MODE_ALL, scholar_profile_id: int | None = None, favorite_only: bool = False, + search: str | None = None, + snapshot_before: datetime | None = None, ) -> int: resolved_mode = resolve_publication_view_mode(mode) - latest_run_id = await get_latest_completed_run_id_for_user(db_session, user_id=user_id) + latest_run_id = await get_latest_run_id_for_user(db_session, user_id=user_id) stmt = ( - select(func.count()) + select(func.count(distinct(ScholarPublication.publication_id))) .select_from(ScholarPublication) .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) + .join(Publication, Publication.id == ScholarPublication.publication_id) .where(ScholarProfile.user_id == user_id) ) + stmt = _apply_search_filter(stmt, search=search) if scholar_profile_id is not None: stmt = stmt.where(ScholarProfile.id == scholar_profile_id) if favorite_only: stmt = stmt.where(ScholarPublication.is_favorite.is_(True)) + if snapshot_before is not None: + stmt = stmt.where(ScholarPublication.created_at <= snapshot_before) if resolved_mode == MODE_UNREAD: stmt = stmt.where(ScholarPublication.is_read.is_(False)) if resolved_mode == MODE_LATEST: @@ -43,12 +51,26 @@ async def count_for_user( return int(result.scalar_one() or 0) +def _apply_search_filter(stmt, *, search: str | None): + if not search: + return stmt + safe_search = search.replace("%", r"\%").replace("_", r"\_") + pattern = f"%{safe_search}%" + return stmt.where( + Publication.title_raw.ilike(pattern) + | ScholarProfile.display_name.ilike(pattern) + | Publication.venue_text.ilike(pattern) + ) + + async def count_unread_for_user( db_session: AsyncSession, *, user_id: int, scholar_profile_id: int | None = None, favorite_only: bool = False, + search: str | None = None, + snapshot_before: datetime | None = None, ) -> int: return await count_for_user( db_session, @@ -56,6 +78,8 @@ async def count_unread_for_user( mode=MODE_UNREAD, scholar_profile_id=scholar_profile_id, favorite_only=favorite_only, + search=search, + snapshot_before=snapshot_before, ) @@ -65,6 +89,8 @@ async def count_latest_for_user( user_id: int, scholar_profile_id: int | None = None, favorite_only: bool = False, + search: str | None = None, + snapshot_before: datetime | None = None, ) -> int: return await count_for_user( db_session, @@ -72,6 +98,8 @@ async def count_latest_for_user( mode=MODE_LATEST, scholar_profile_id=scholar_profile_id, favorite_only=favorite_only, + search=search, + snapshot_before=snapshot_before, ) @@ -80,6 +108,8 @@ async def count_favorite_for_user( *, user_id: int, scholar_profile_id: int | None = None, + search: str | None = None, + snapshot_before: datetime | None = None, ) -> int: return await count_for_user( db_session, @@ -87,4 +117,6 @@ async def count_favorite_for_user( mode=MODE_ALL, scholar_profile_id=scholar_profile_id, favorite_only=True, + search=search, + snapshot_before=snapshot_before, ) diff --git a/app/services/publications/dedup.py b/app/services/publications/dedup.py new file mode 100644 index 0000000..d078f97 --- /dev/null +++ b/app/services/publications/dedup.py @@ -0,0 +1,502 @@ +from __future__ import annotations + +import hashlib +import logging +from collections.abc import Iterable +from dataclasses import dataclass + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from app.db.models import Publication, PublicationIdentifier, ScholarPublication +from app.logging_utils import structured_log +from app.services.ingestion.fingerprints import ( + canonical_title_text_for_dedup, + canonical_title_tokens_for_dedup, + normalize_title, +) + +logger = logging.getLogger(__name__) + +NEAR_DUP_DEFAULT_SIMILARITY_THRESHOLD = 0.78 +NEAR_DUP_DEFAULT_CONTAINMENT_THRESHOLD = 0.92 +NEAR_DUP_DEFAULT_MIN_SHARED_TOKENS = 3 +NEAR_DUP_DEFAULT_MAX_YEAR_DELTA = 1 +NEAR_DUP_MIN_TOKEN_LENGTH = 3 +NEAR_DUP_CLUSTER_KEY_LENGTH = 16 +NEAR_DUP_STOPWORDS = { + "a", + "an", + "and", + "approach", + "for", + "in", + "method", + "of", + "on", + "the", + "to", + "using", + "via", + "with", +} + + +@dataclass(frozen=True) +class NearDuplicateMember: + publication_id: int + title: str + year: int | None + citation_count: int + + +@dataclass(frozen=True) +class NearDuplicateCluster: + cluster_key: str + winner_publication_id: int + similarity_score: float + members: tuple[NearDuplicateMember, ...] + + +@dataclass(frozen=True) +class _NearDuplicateCandidate: + publication_id: int + title: str + year: int | None + citation_count: int + canonical_text: str + tokens: frozenset[str] + + +async def find_identifier_duplicate_pairs( + db_session: AsyncSession, +) -> list[tuple[int, int]]: + """Return (winner_id, dup_id) pairs where two publications share the same identifier.""" + pi1 = aliased(PublicationIdentifier, name="pi1") + pi2 = aliased(PublicationIdentifier, name="pi2") + rows = await db_session.execute( + select(pi1.publication_id, pi2.publication_id) + .join( + pi2, + (pi1.kind == pi2.kind) + & (pi1.value_normalized == pi2.value_normalized) + & (pi1.publication_id < pi2.publication_id), + ) + .distinct() + ) + return [(winner_id, dup_id) for winner_id, dup_id in rows] + + +async def merge_duplicate_publication( + db_session: AsyncSession, + *, + winner_id: int, + dup_id: int, +) -> None: + """Merge dup_id into winner_id: migrate metadata/links/identifiers, then delete dup.""" + if winner_id == dup_id: + raise ValueError("winner_id and dup_id must differ.") + winner = await _load_publication(db_session, publication_id=winner_id) + dup = await _load_publication(db_session, publication_id=dup_id) + if winner is None or dup is None: + raise ValueError("winner_id and dup_id must both exist.") + _merge_publication_metadata(winner=winner, dup=dup) + await _migrate_scholar_links(db_session, winner_id=winner_id, dup_id=dup_id) + await _migrate_identifiers(db_session, winner_id=winner_id, dup_id=dup_id) + await db_session.execute(delete(Publication).where(Publication.id == dup_id)) + structured_log(logger, "info", "publications.identifier_merge", winner_id=winner_id, dup_id=dup_id) + + +async def _load_publication( + db_session: AsyncSession, + *, + publication_id: int, +) -> Publication | None: + result = await db_session.execute(select(Publication).where(Publication.id == publication_id)) + return result.scalar_one_or_none() + + +def _merge_publication_metadata(*, winner: Publication, dup: Publication) -> None: + if winner.year is None and dup.year is not None: + winner.year = dup.year + winner.citation_count = max(int(winner.citation_count or 0), int(dup.citation_count or 0)) + if not winner.author_text and dup.author_text: + winner.author_text = dup.author_text + if not winner.venue_text and dup.venue_text: + winner.venue_text = dup.venue_text + if not winner.pub_url and dup.pub_url: + winner.pub_url = dup.pub_url + if not winner.pdf_url and dup.pdf_url: + winner.pdf_url = dup.pdf_url + if not winner.cluster_id and dup.cluster_id: + winner.cluster_id = dup.cluster_id + if not winner.canonical_title_hash and dup.canonical_title_hash: + winner.canonical_title_hash = dup.canonical_title_hash + winner.title_raw = _preferred_title_text(winner=winner.title_raw, dup=dup.title_raw) + winner.title_normalized = normalize_title(winner.title_raw) + + +def _preferred_title_text(*, winner: str, dup: str) -> str: + winner_score = len(canonical_title_text_for_dedup(winner)) + dup_score = len(canonical_title_text_for_dedup(dup)) + if dup_score > winner_score: + return dup + return winner + + +async def _migrate_scholar_links( + db_session: AsyncSession, + *, + winner_id: int, + dup_id: int, +) -> None: + """Move ScholarPublication links from dup to winner, dropping conflicts.""" + dup_links_result = await db_session.execute( + select(ScholarPublication).where(ScholarPublication.publication_id == dup_id) + ) + dup_links = dup_links_result.scalars().all() + + winner_profiles_result = await db_session.execute( + select(ScholarPublication.scholar_profile_id).where(ScholarPublication.publication_id == winner_id) + ) + winner_profiles: set[int] = {row for (row,) in winner_profiles_result} + + for link in dup_links: + if link.scholar_profile_id in winner_profiles: + await db_session.delete(link) + else: + link.publication_id = winner_id + + +async def _migrate_identifiers( + db_session: AsyncSession, + *, + winner_id: int, + dup_id: int, +) -> None: + result = await db_session.execute( + select(PublicationIdentifier).where(PublicationIdentifier.publication_id == dup_id) + ) + dup_identifiers = result.scalars().all() + for identifier in dup_identifiers: + existing = await _find_identifier( + db_session, + publication_id=winner_id, + kind=identifier.kind, + value_normalized=identifier.value_normalized, + ) + if existing is None: + identifier.publication_id = winner_id + continue + _merge_identifier(existing=existing, dup=identifier) + await db_session.delete(identifier) + + +async def _find_identifier( + db_session: AsyncSession, + *, + publication_id: int, + kind: str, + value_normalized: str, +) -> PublicationIdentifier | None: + result = await db_session.execute( + select(PublicationIdentifier).where( + PublicationIdentifier.publication_id == publication_id, + PublicationIdentifier.kind == kind, + PublicationIdentifier.value_normalized == value_normalized, + ) + ) + return result.scalar_one_or_none() + + +def _merge_identifier(*, existing: PublicationIdentifier, dup: PublicationIdentifier) -> None: + existing.confidence_score = max( + float(existing.confidence_score), + float(dup.confidence_score), + ) + if not existing.evidence_url and dup.evidence_url: + existing.evidence_url = dup.evidence_url + if not existing.value_raw and dup.value_raw: + existing.value_raw = dup.value_raw + + +async def sweep_identifier_duplicates(db_session: AsyncSession) -> int: + """Find publications sharing an identifier and merge duplicates into the winner.""" + pairs = await find_identifier_duplicate_pairs(db_session) + if not pairs: + return 0 + + processed_dups: set[int] = set() + for winner_id, dup_id in pairs: + if dup_id in processed_dups: + continue + processed_dups.add(dup_id) + await merge_duplicate_publication(db_session, winner_id=winner_id, dup_id=dup_id) + + await db_session.flush() + return len(processed_dups) + + +async def find_near_duplicate_clusters( + db_session: AsyncSession, + *, + similarity_threshold: float = NEAR_DUP_DEFAULT_SIMILARITY_THRESHOLD, + min_shared_tokens: int = NEAR_DUP_DEFAULT_MIN_SHARED_TOKENS, + max_year_delta: int = NEAR_DUP_DEFAULT_MAX_YEAR_DELTA, +) -> list[NearDuplicateCluster]: + candidates = await _load_near_duplicate_candidates(db_session) + if len(candidates) < 2: + return [] + groups = _cluster_candidate_groups( + candidates, + similarity_threshold=similarity_threshold, + min_shared_tokens=min_shared_tokens, + max_year_delta=max_year_delta, + ) + clusters = [_near_duplicate_cluster(group) for group in groups] + return sorted(clusters, key=lambda item: (-len(item.members), item.winner_publication_id)) + + +async def merge_near_duplicate_cluster( + db_session: AsyncSession, + *, + cluster: NearDuplicateCluster, +) -> int: + winner_id = int(cluster.winner_publication_id) + merged = 0 + for member in cluster.members: + if int(member.publication_id) == winner_id: + continue + await merge_duplicate_publication( + db_session, + winner_id=winner_id, + dup_id=int(member.publication_id), + ) + merged += 1 + return merged + + +def near_duplicate_cluster_payload(cluster: NearDuplicateCluster) -> dict[str, object]: + members = [ + { + "publication_id": int(member.publication_id), + "title": member.title, + "year": member.year, + "citation_count": int(member.citation_count), + } + for member in cluster.members + ] + return { + "cluster_key": cluster.cluster_key, + "winner_publication_id": int(cluster.winner_publication_id), + "member_count": len(cluster.members), + "similarity_score": float(cluster.similarity_score), + "members": members, + } + + +async def _load_near_duplicate_candidates( + db_session: AsyncSession, +) -> list[_NearDuplicateCandidate]: + result = await db_session.execute( + select( + Publication.id, + Publication.title_raw, + Publication.year, + Publication.citation_count, + ) + ) + records = [ + _candidate_from_row( + publication_id=int(publication_id), + title=str(title_raw or ""), + year=year, + citation_count=int(citation_count or 0), + ) + for publication_id, title_raw, year, citation_count in result.all() + ] + return [record for record in records if record is not None] + + +def _candidate_from_row( + *, + publication_id: int, + title: str, + year: int | None, + citation_count: int, +) -> _NearDuplicateCandidate | None: + canonical = canonical_title_text_for_dedup(title) + raw_tokens = canonical_title_tokens_for_dedup(title) + tokens = _normalized_tokens(raw_tokens) + if not canonical or not tokens: + return None + return _NearDuplicateCandidate( + publication_id=publication_id, + title=title, + year=year, + citation_count=citation_count, + canonical_text=canonical, + tokens=frozenset(tokens), + ) + + +def _normalized_tokens(tokens: Iterable[str]) -> set[str]: + return {token for token in tokens if len(token) >= NEAR_DUP_MIN_TOKEN_LENGTH and token not in NEAR_DUP_STOPWORDS} + + +def _cluster_candidate_groups( + candidates: list[_NearDuplicateCandidate], + *, + similarity_threshold: float, + min_shared_tokens: int, + max_year_delta: int, +) -> list[list[_NearDuplicateCandidate]]: + by_id = {candidate.publication_id: candidate for candidate in candidates} + token_index = _candidate_token_index(candidates) + parent = {candidate.publication_id: candidate.publication_id for candidate in candidates} + for candidate in candidates: + peers = _candidate_peer_ids(candidate=candidate, token_index=token_index) + for peer_id in sorted(peers): + if peer_id <= candidate.publication_id: + continue + peer = by_id[peer_id] + if _is_near_duplicate_pair( + candidate, + peer, + similarity_threshold=similarity_threshold, + min_shared_tokens=min_shared_tokens, + max_year_delta=max_year_delta, + ): + _union(parent, candidate.publication_id, peer_id) + return _grouped_candidates(candidates, parent) + + +def _candidate_token_index( + candidates: list[_NearDuplicateCandidate], +) -> dict[str, set[int]]: + index: dict[str, set[int]] = {} + for candidate in candidates: + for token in candidate.tokens: + index.setdefault(token, set()).add(candidate.publication_id) + return index + + +def _candidate_peer_ids( + *, + candidate: _NearDuplicateCandidate, + token_index: dict[str, set[int]], +) -> set[int]: + peers: set[int] = set() + for token in candidate.tokens: + peers.update(token_index.get(token, set())) + peers.discard(candidate.publication_id) + return peers + + +def _is_near_duplicate_pair( + left: _NearDuplicateCandidate, + right: _NearDuplicateCandidate, + *, + similarity_threshold: float, + min_shared_tokens: int, + max_year_delta: int, +) -> bool: + if left.canonical_text == right.canonical_text: + return True + if not _years_compatible(left.year, right.year, max_year_delta=max_year_delta): + return False + shared_tokens = len(left.tokens & right.tokens) + if shared_tokens < min_shared_tokens: + return False + jaccard = _jaccard(left.tokens, right.tokens) + containment = shared_tokens / max(1, min(len(left.tokens), len(right.tokens))) + return jaccard >= similarity_threshold or containment >= NEAR_DUP_DEFAULT_CONTAINMENT_THRESHOLD + + +def _years_compatible(left: int | None, right: int | None, *, max_year_delta: int) -> bool: + if left is None or right is None: + return True + return abs(int(left) - int(right)) <= int(max_year_delta) + + +def _jaccard(left: frozenset[str], right: frozenset[str]) -> float: + if not left or not right: + return 0.0 + return len(left & right) / len(left | right) + + +def _find_root(parent: dict[int, int], value: int) -> int: + root = parent[value] + while root != parent[root]: + root = parent[root] + while value != root: + next_value = parent[value] + parent[value] = root + value = next_value + return root + + +def _union(parent: dict[int, int], left: int, right: int) -> None: + left_root = _find_root(parent, left) + right_root = _find_root(parent, right) + if left_root == right_root: + return + if left_root < right_root: + parent[right_root] = left_root + return + parent[left_root] = right_root + + +def _grouped_candidates( + candidates: list[_NearDuplicateCandidate], + parent: dict[int, int], +) -> list[list[_NearDuplicateCandidate]]: + groups: dict[int, list[_NearDuplicateCandidate]] = {} + for candidate in candidates: + root = _find_root(parent, candidate.publication_id) + groups.setdefault(root, []).append(candidate) + clustered = [members for members in groups.values() if len(members) > 1] + for members in clustered: + members.sort(key=lambda item: item.publication_id) + return clustered + + +def _near_duplicate_cluster(members: list[_NearDuplicateCandidate]) -> NearDuplicateCluster: + winner = _winner_candidate(members) + member_ids = [member.publication_id for member in members] + joined = ",".join(str(publication_id) for publication_id in member_ids) + cluster_key = hashlib.sha256(joined.encode("utf-8")).hexdigest()[:NEAR_DUP_CLUSTER_KEY_LENGTH] + similarity_score = _cluster_similarity_score(members) + return NearDuplicateCluster( + cluster_key=cluster_key, + winner_publication_id=winner.publication_id, + similarity_score=similarity_score, + members=tuple( + NearDuplicateMember( + publication_id=member.publication_id, + title=member.title, + year=member.year, + citation_count=member.citation_count, + ) + for member in members + ), + ) + + +def _winner_candidate(members: list[_NearDuplicateCandidate]) -> _NearDuplicateCandidate: + return min( + members, + key=lambda member: (-int(member.citation_count), member.publication_id), + ) + + +def _cluster_similarity_score(members: list[_NearDuplicateCandidate]) -> float: + best = 0.0 + for index, left in enumerate(members): + for right in members[index + 1 :]: + shared_tokens = len(left.tokens & right.tokens) + jaccard = _jaccard(left.tokens, right.tokens) + containment = shared_tokens / max(1, min(len(left.tokens), len(right.tokens))) + best = max(best, jaccard, containment) + return round(best, 4) diff --git a/app/services/domains/publications/enrichment.py b/app/services/publications/enrichment.py similarity index 68% rename from app/services/domains/publications/enrichment.py rename to app/services/publications/enrichment.py index 381107a..3980d05 100644 --- a/app/services/domains/publications/enrichment.py +++ b/app/services/publications/enrichment.py @@ -4,12 +4,13 @@ import logging from sqlalchemy.ext.asyncio import AsyncSession -from app.services.domains.publications.pdf_queue import ( +from app.logging_utils import structured_log +from app.services.publications.pdf_queue import ( enqueue_missing_pdf_jobs, enqueue_retry_pdf_job, overlay_pdf_job_state, ) -from app.services.domains.publications.types import PublicationListItem +from app.services.publications.types import PublicationListItem logger = logging.getLogger(__name__) @@ -29,13 +30,8 @@ async def schedule_missing_pdf_enrichment_for_user( rows=items, max_items=max_items, ) - logger.info( - "publications.enrichment.scheduled", - extra={ - "event": "publications.enrichment.scheduled", - "user_id": user_id, - "publication_count": len(queued_ids), - }, + structured_log( + logger, "info", "publications.enrichment.scheduled", user_id=user_id, publication_count=len(queued_ids) ) return len(queued_ids) @@ -53,14 +49,13 @@ async def schedule_retry_pdf_enrichment_for_row( request_email=request_email, row=item, ) - logger.info( + structured_log( + logger, + "info", "publications.enrichment.retry_scheduled", - extra={ - "event": "publications.enrichment.retry_scheduled", - "user_id": user_id, - "publication_id": item.publication_id, - "queued": queued, - }, + user_id=user_id, + publication_id=item.publication_id, + queued=queued, ) return queued diff --git a/app/services/domains/publications/listing.py b/app/services/publications/listing.py similarity index 58% rename from app/services/domains/publications/listing.py rename to app/services/publications/listing.py index f4f518b..6bb50b2 100644 --- a/app/services/domains/publications/listing.py +++ b/app/services/publications/listing.py @@ -1,20 +1,23 @@ from __future__ import annotations +from datetime import datetime + from sqlalchemy.ext.asyncio import AsyncSession -from app.services.domains.publications.modes import ( +from app.services.publication_identifiers import application as identifier_service +from app.services.publications.modes import ( MODE_ALL, MODE_UNREAD, resolve_publication_view_mode, ) -from app.services.domains.publications.queries import ( - get_latest_completed_run_id_for_user, +from app.services.publications.queries import ( + get_latest_run_id_for_user, get_publication_item_for_user, publication_list_item_from_row, publications_query, unread_item_from_row, ) -from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem +from app.services.publications.types import PublicationListItem, UnreadPublicationItem async def list_for_user( @@ -24,11 +27,15 @@ async def list_for_user( mode: str = MODE_ALL, scholar_profile_id: int | None = None, favorite_only: bool = False, - limit: int = 300, + search: str | None = None, + sort_by: str = "first_seen", + sort_dir: str = "desc", + limit: int = 100, offset: int = 0, + snapshot_before: datetime | None = None, ) -> list[PublicationListItem]: resolved_mode = resolve_publication_view_mode(mode) - latest_run_id = await get_latest_completed_run_id_for_user(db_session, user_id=user_id) + latest_run_id = await get_latest_run_id_for_user(db_session, user_id=user_id) result = await db_session.execute( publications_query( user_id=user_id, @@ -36,15 +43,19 @@ async def list_for_user( latest_run_id=latest_run_id, scholar_profile_id=scholar_profile_id, favorite_only=favorite_only, + search=search, + sort_by=sort_by, + sort_dir=sort_dir, limit=limit, offset=offset, + snapshot_before=snapshot_before, ) ) - rows = [ - publication_list_item_from_row(row, latest_run_id=latest_run_id) - for row in result.all() - ] - return rows + rows = [publication_list_item_from_row(row, latest_run_id=latest_run_id) for row in result.all()] + return await identifier_service.overlay_publication_items_with_display_identifiers( + db_session, + items=rows, + ) async def retry_pdf_for_user( @@ -54,12 +65,19 @@ async def retry_pdf_for_user( scholar_profile_id: int, publication_id: int, ) -> PublicationListItem | None: - return await get_publication_item_for_user( + item = await get_publication_item_for_user( db_session, user_id=user_id, scholar_profile_id=scholar_profile_id, publication_id=publication_id, ) + if item is None: + return None + hydrated = await identifier_service.overlay_publication_items_with_display_identifiers( + db_session, + items=[item], + ) + return hydrated[0] if hydrated else item async def list_unread_for_user( diff --git a/app/services/domains/publications/modes.py b/app/services/publications/modes.py similarity index 100% rename from app/services/domains/publications/modes.py rename to app/services/publications/modes.py diff --git a/app/services/publications/pdf_queue.py b/app/services/publications/pdf_queue.py new file mode 100644 index 0000000..25f2437 --- /dev/null +++ b/app/services/publications/pdf_queue.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ( + PublicationPdfJob, + User, +) +from app.services.publications.pdf_queue_common import ( + PDF_STATUS_FAILED, + PDF_STATUS_QUEUED, + PDF_STATUS_RESOLVED, + PDF_STATUS_RUNNING, + event_row, + queued_job, + utcnow, +) +from app.services.publications.pdf_queue_queries import ( + missing_pdf_candidates, + retry_item_for_publication_id, +) +from app.services.publications.pdf_queue_resolution import schedule_rows +from app.services.publications.types import PublicationListItem +from app.settings import settings + +PDF_STATUS_UNTRACKED = "untracked" + +PDF_EVENT_QUEUED = "queued" + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class PdfRequeueResult: + publication_exists: bool + queued: bool + + +@dataclass(frozen=True) +class PdfBulkQueueResult: + requested_count: int + queued_count: int + + +def _publication_ids(rows: list[PublicationListItem]) -> list[int]: + return sorted({row.publication_id for row in rows}) + + +def _status_from_job(row: PublicationListItem, job: PublicationPdfJob | None) -> str: + if row.pdf_url: + return PDF_STATUS_RESOLVED + if job is None: + return PDF_STATUS_UNTRACKED + return job.status + + +def _item_from_row_and_job( + row: PublicationListItem, + job: PublicationPdfJob | None, +) -> PublicationListItem: + return PublicationListItem( + publication_id=row.publication_id, + scholar_profile_id=row.scholar_profile_id, + scholar_label=row.scholar_label, + title=row.title, + year=row.year, + citation_count=row.citation_count, + venue_text=row.venue_text, + pub_url=row.pub_url, + pdf_url=row.pdf_url, + is_read=row.is_read, + is_favorite=row.is_favorite, + first_seen_at=row.first_seen_at, + is_new_in_latest_run=row.is_new_in_latest_run, + pdf_status=_status_from_job(row, job), + pdf_attempt_count=int(job.attempt_count) if job is not None else 0, + pdf_failure_reason=job.last_failure_reason if job is not None else None, + pdf_failure_detail=job.last_failure_detail if job is not None else None, + display_identifier=row.display_identifier, + ) + + +def _queueable_rows( + rows: list[PublicationListItem], + *, + max_items: int, +) -> list[PublicationListItem]: + bounded = max(0, int(max_items)) + if bounded == 0: + return [] + candidates = [row for row in rows if not row.pdf_url] + return candidates[:bounded] + + +def _auto_retry_interval_seconds() -> int: + return max(int(settings.pdf_auto_retry_interval_seconds), 1) + + +def _auto_retry_first_interval_seconds() -> int: + return max(int(settings.pdf_auto_retry_first_interval_seconds), 1) + + +def _auto_retry_max_attempts() -> int: + return max(int(settings.pdf_auto_retry_max_attempts), 1) + + +def _retry_interval_seconds_for_attempt_count(attempt_count: int) -> int: + if int(attempt_count) <= 1: + return _auto_retry_first_interval_seconds() + return _auto_retry_interval_seconds() + + +def _cooldown_active( + *, + last_attempt_at: datetime | None, + attempt_count: int, +) -> bool: + if last_attempt_at is None: + return False + elapsed = (utcnow() - last_attempt_at).total_seconds() + return elapsed < _retry_interval_seconds_for_attempt_count(int(attempt_count)) + + +def _can_enqueue_job( + job: PublicationPdfJob | None, + *, + force_retry: bool, +) -> bool: + if job is None: + return True + if job.status in {PDF_STATUS_QUEUED, PDF_STATUS_RUNNING}: + return False + if force_retry: + return job.status in {PDF_STATUS_FAILED, PDF_STATUS_RESOLVED, PDF_STATUS_UNTRACKED} + if job.status == PDF_STATUS_RESOLVED: + return False + if int(job.attempt_count) >= _auto_retry_max_attempts(): + return False + return not _cooldown_active( + last_attempt_at=job.last_attempt_at, + attempt_count=int(job.attempt_count), + ) + + +def _mark_job_queued(job: PublicationPdfJob, *, user_id: int) -> None: + now = utcnow() + job.status = PDF_STATUS_QUEUED + job.queued_at = now + job.last_requested_by_user_id = user_id + job.last_failure_reason = None + job.last_failure_detail = None + job.last_source = None + + +def _state_map(jobs: list[PublicationPdfJob]) -> dict[int, PublicationPdfJob]: + return {int(job.publication_id): job for job in jobs} + + +async def _jobs_for_publication_ids( + db_session: AsyncSession, + *, + publication_ids: list[int], +) -> dict[int, PublicationPdfJob]: + if not publication_ids: + return {} + result = await db_session.execute( + select(PublicationPdfJob).where(PublicationPdfJob.publication_id.in_(publication_ids)) + ) + return _state_map(list(result.scalars())) + + +async def overlay_pdf_job_state( + db_session: AsyncSession, + *, + rows: list[PublicationListItem], +) -> list[PublicationListItem]: + if not rows: + return [] + jobs = await _jobs_for_publication_ids( + db_session, + publication_ids=_publication_ids(rows), + ) + return [_item_from_row_and_job(row, jobs.get(row.publication_id)) for row in rows] + + +async def _enqueue_rows( + db_session: AsyncSession, + *, + user_id: int, + rows: list[PublicationListItem], + force_retry: bool, +) -> list[PublicationListItem]: + if not rows: + return [] + queued: list[PublicationListItem] = [] + jobs = await _jobs_for_publication_ids( + db_session, + publication_ids=_publication_ids(rows), + ) + for row in rows: + job = jobs.get(row.publication_id) + if not _can_enqueue_job(job, force_retry=force_retry): + continue + if job is None: + job = queued_job(publication_id=row.publication_id, user_id=user_id) + jobs[row.publication_id] = job + db_session.add(job) + else: + _mark_job_queued(job, user_id=user_id) + db_session.add( + event_row( + publication_id=row.publication_id, + user_id=user_id, + event_type=PDF_EVENT_QUEUED, + status=PDF_STATUS_QUEUED, + ) + ) + queued.append(row) + if queued: + await db_session.commit() + return queued + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def enqueue_missing_pdf_jobs( + db_session: AsyncSession, + *, + user_id: int, + request_email: str | None, + rows: list[PublicationListItem], + max_items: int, +) -> list[int]: + queueable = _queueable_rows(rows, max_items=max_items) + queued_rows = await _enqueue_rows( + db_session, + user_id=user_id, + rows=queueable, + force_retry=False, + ) + schedule_rows(user_id=user_id, request_email=request_email, rows=queued_rows) + return [row.publication_id for row in queued_rows] + + +async def enqueue_retry_pdf_job( + db_session: AsyncSession, + *, + user_id: int, + request_email: str | None, + row: PublicationListItem, +) -> bool: + queued_rows = await _enqueue_rows( + db_session, + user_id=user_id, + rows=[row], + force_retry=True, + ) + schedule_rows(user_id=user_id, request_email=request_email, rows=queued_rows) + return bool(queued_rows) + + +async def enqueue_retry_pdf_job_for_publication_id( + db_session: AsyncSession, + *, + user_id: int, + request_email: str | None, + publication_id: int, +) -> PdfRequeueResult: + row = await retry_item_for_publication_id( + db_session, + publication_id=publication_id, + ) + if row is None: + return PdfRequeueResult(publication_exists=False, queued=False) + queued = await enqueue_retry_pdf_job( + db_session, + user_id=user_id, + request_email=request_email, + row=row, + ) + return PdfRequeueResult(publication_exists=True, queued=queued) + + +async def enqueue_all_missing_pdf_jobs( + db_session: AsyncSession, + *, + user_id: int, + request_email: str | None, + limit: int = 1000, +) -> PdfBulkQueueResult: + candidates = await missing_pdf_candidates(db_session, limit=limit) + queued_rows = await _enqueue_rows( + db_session, + user_id=user_id, + rows=candidates, + force_retry=True, + ) + schedule_rows(user_id=user_id, request_email=request_email, rows=queued_rows) + return PdfBulkQueueResult( + requested_count=len(candidates), + queued_count=len(queued_rows), + ) + + +async def drain_ready_jobs( + db_session: AsyncSession, + *, + limit: int, + max_attempts: int, +) -> int: + result = await db_session.execute(select(User.id).where(User.is_active.is_(True)).order_by(User.id.asc()).limit(1)) + system_user_id = result.scalar_one_or_none() + if system_user_id is None: + return 0 + + bulk_result = await enqueue_all_missing_pdf_jobs( + db_session, + user_id=system_user_id, + request_email=settings.unpaywall_email, + limit=limit, + ) + return bulk_result.queued_count diff --git a/app/services/publications/pdf_queue_common.py b/app/services/publications/pdf_queue_common.py new file mode 100644 index 0000000..0bff88b --- /dev/null +++ b/app/services/publications/pdf_queue_common.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from app.db.models import PublicationPdfJob, PublicationPdfJobEvent + +PDF_STATUS_UNTRACKED = "untracked" +PDF_STATUS_QUEUED = "queued" +PDF_STATUS_RUNNING = "running" +PDF_STATUS_RESOLVED = "resolved" +PDF_STATUS_FAILED = "failed" + + +def utcnow() -> datetime: + return datetime.now(UTC) + + +def event_row( + *, + publication_id: int, + user_id: int | None, + event_type: str, + status: str | None, + source: str | None = None, + failure_reason: str | None = None, + message: str | None = None, +) -> PublicationPdfJobEvent: + return PublicationPdfJobEvent( + publication_id=publication_id, + user_id=user_id, + event_type=event_type, + status=status, + source=source, + failure_reason=failure_reason, + message=message, + ) + + +def queued_job( + *, + publication_id: int, + user_id: int, +) -> PublicationPdfJob: + now = utcnow() + return PublicationPdfJob( + publication_id=publication_id, + status=PDF_STATUS_QUEUED, + queued_at=now, + last_requested_by_user_id=user_id, + attempt_count=0, + ) diff --git a/app/services/publications/pdf_queue_queries.py b/app/services/publications/pdf_queue_queries.py new file mode 100644 index 0000000..31260ea --- /dev/null +++ b/app/services/publications/pdf_queue_queries.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any + +from sqlalchemy import Select, and_, func, literal, or_, select, union_all +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ( + Publication, + PublicationPdfJob, + ScholarProfile, + ScholarPublication, + User, +) +from app.services.publication_identifiers import application as identifier_service +from app.services.publication_identifiers.types import DisplayIdentifier +from app.services.publications.pdf_queue_common import ( + PDF_STATUS_QUEUED, + PDF_STATUS_RUNNING, + PDF_STATUS_UNTRACKED, +) +from app.services.publications.types import PublicationListItem + + +def _bounded_limit(limit: int, *, max_value: int = 500) -> int: + return max(1, min(int(limit), max_value)) + + +def _bounded_offset(offset: int) -> int: + return max(int(offset), 0) + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +def _retry_item_label(display_name: str | None, scholar_id: str | None) -> str: + return str(display_name or scholar_id or "unknown") + + +def _retry_item_from_publication( + publication: Publication, + *, + link_row: Any | None, +) -> PublicationListItem: + if link_row is None: + scholar_profile_id = 0 + scholar_label = "unknown" + is_read = True + first_seen_at = publication.created_at or _utcnow() + else: + scholar_profile_id = int(link_row[0]) + scholar_label = _retry_item_label(link_row[1], link_row[2]) + is_read = bool(link_row[3]) + first_seen_at = link_row[4] or publication.created_at or _utcnow() + return PublicationListItem( + publication_id=int(publication.id), + scholar_profile_id=scholar_profile_id, + scholar_label=scholar_label, + title=publication.title_raw, + year=publication.year, + citation_count=int(publication.citation_count or 0), + venue_text=publication.venue_text, + pub_url=publication.pub_url, + pdf_url=publication.pdf_url, + is_read=is_read, + first_seen_at=first_seen_at, + is_new_in_latest_run=False, + ) + + +async def retry_item_for_publication_id( + db_session: AsyncSession, + *, + publication_id: int, +) -> PublicationListItem | None: + publication = await db_session.get(Publication, publication_id) + if publication is None: + return None + result = await db_session.execute( + select( + ScholarProfile.id, + ScholarProfile.display_name, + ScholarProfile.scholar_id, + ScholarPublication.is_read, + ScholarPublication.created_at, + ) + .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) + .where(ScholarPublication.publication_id == publication_id) + .order_by(ScholarPublication.created_at.asc()) + .limit(1) + ) + return _retry_item_from_publication(publication, link_row=result.one_or_none()) + + +def _queue_candidate_from_publication(publication: Publication) -> PublicationListItem: + return PublicationListItem( + publication_id=int(publication.id), + scholar_profile_id=0, + scholar_label="", + title=publication.title_raw, + year=publication.year, + citation_count=int(publication.citation_count or 0), + venue_text=publication.venue_text, + pub_url=publication.pub_url, + pdf_url=publication.pdf_url, + is_read=True, + first_seen_at=publication.created_at or _utcnow(), + is_new_in_latest_run=False, + ) + + +async def missing_pdf_candidates( + db_session: AsyncSession, + *, + limit: int, +) -> list[PublicationListItem]: + bounded_limit = max(1, min(int(limit), 5000)) + now = datetime.now(UTC) + cooldown_threshold = now - timedelta(days=7) + + result = await db_session.execute( + select(Publication) + .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) + .where(Publication.pdf_url.is_(None)) + .where( + or_( + PublicationPdfJob.publication_id.is_(None), + and_( + PublicationPdfJob.status.notin_([PDF_STATUS_QUEUED, PDF_STATUS_RUNNING]), + or_( + PublicationPdfJob.last_attempt_at.is_(None), + PublicationPdfJob.last_attempt_at < cooldown_threshold, + ), + ), + ) + ) + .order_by(Publication.updated_at.desc(), Publication.id.desc()) + .limit(bounded_limit) + ) + return [_queue_candidate_from_publication(publication) for publication in result.scalars()] + + +# --------------------------------------------------------------------------- +# Tracked / untracked queue SQL builders +# --------------------------------------------------------------------------- + + +def _tracked_queue_select_base(*, status: str | None) -> Select[tuple]: + stmt = ( + select( + PublicationPdfJob.publication_id, + Publication.title_raw, + Publication.pdf_url, + PublicationPdfJob.status, + PublicationPdfJob.attempt_count, + PublicationPdfJob.last_failure_reason, + PublicationPdfJob.last_failure_detail, + PublicationPdfJob.last_source, + PublicationPdfJob.last_requested_by_user_id, + User.email, + PublicationPdfJob.queued_at, + PublicationPdfJob.last_attempt_at, + PublicationPdfJob.resolved_at, + PublicationPdfJob.updated_at, + ) + .join(Publication, Publication.id == PublicationPdfJob.publication_id) + .outerjoin(User, User.id == PublicationPdfJob.last_requested_by_user_id) + ) + if status: + stmt = stmt.where(PublicationPdfJob.status == status) + return stmt + + +def _tracked_queue_select(*, limit: int, offset: int, status: str | None) -> Select[tuple]: + return ( + _tracked_queue_select_base(status=status) + .order_by(PublicationPdfJob.updated_at.desc()) + .limit(_bounded_limit(limit)) + .offset(_bounded_offset(offset)) + ) + + +def _untracked_queue_select_base() -> Select[tuple]: + return ( + select( + Publication.id, + Publication.title_raw, + Publication.pdf_url, + literal(PDF_STATUS_UNTRACKED), + literal(0), + literal(None), + literal(None), + literal(None), + literal(None), + literal(None), + literal(None), + literal(None), + literal(None), + Publication.updated_at, + ) + .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) + .where(Publication.pdf_url.is_(None)) + .where(PublicationPdfJob.publication_id.is_(None)) + ) + + +def _untracked_queue_select(*, limit: int, offset: int) -> Select[tuple]: + return ( + _untracked_queue_select_base() + .order_by(Publication.updated_at.desc(), Publication.id.desc()) + .limit(_bounded_limit(limit)) + .offset(_bounded_offset(offset)) + ) + + +def _all_queue_select(*, limit: int, offset: int) -> Select[tuple]: + union_stmt = union_all( + _tracked_queue_select_base(status=None), + _untracked_queue_select_base(), + ).subquery() + return ( + select(union_stmt) + .order_by(union_stmt.c.updated_at.desc()) + .limit(_bounded_limit(limit)) + .offset(_bounded_offset(offset)) + ) + + +def _tracked_queue_count_select(*, status: str | None) -> Select[tuple]: + stmt = select(func.count()).select_from(PublicationPdfJob) + if status: + stmt = stmt.where(PublicationPdfJob.status == status) + return stmt + + +def _untracked_queue_count_select() -> Select[tuple]: + return ( + select(func.count()) + .select_from(Publication) + .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) + .where(Publication.pdf_url.is_(None)) + .where(PublicationPdfJob.publication_id.is_(None)) + ) + + +# --------------------------------------------------------------------------- +# Row hydration +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PdfQueueListItem: + publication_id: int + title: str + pdf_url: str | None + status: str + attempt_count: int + last_failure_reason: str | None + last_failure_detail: str | None + last_source: str | None + requested_by_user_id: int | None + requested_by_email: str | None + queued_at: datetime | None + last_attempt_at: datetime | None + resolved_at: datetime | None + updated_at: datetime + display_identifier: DisplayIdentifier | None = None + + +def _queue_item_from_row(row: Any) -> PdfQueueListItem: + return PdfQueueListItem( + publication_id=int(row[0]), + title=str(row[1] or ""), + pdf_url=row[2], + status=str(row[3] or PDF_STATUS_UNTRACKED), + attempt_count=int(row[4] or 0), + last_failure_reason=row[5], + last_failure_detail=row[6], + last_source=row[7], + requested_by_user_id=int(row[8]) if row[8] is not None else None, + requested_by_email=row[9], + queued_at=row[10], + last_attempt_at=row[11], + resolved_at=row[12], + updated_at=row[13], + ) + + +async def _hydrated_queue_items( + db_session: AsyncSession, + *, + rows: list[Any], +) -> list[PdfQueueListItem]: + items = [_queue_item_from_row(row) for row in rows] + return await identifier_service.overlay_pdf_queue_items_with_display_identifiers( + db_session, + items=items, + ) + + +# --------------------------------------------------------------------------- +# Public listing / counting +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PdfQueuePage: + items: list[PdfQueueListItem] + total_count: int + limit: int + offset: int + + +async def list_pdf_queue_items( + db_session: AsyncSession, + *, + limit: int = 100, + offset: int = 0, + status: str | None = None, +) -> list[PdfQueueListItem]: + bounded_limit = _bounded_limit(limit) + bounded_offset = _bounded_offset(offset) + normalized_status = (status or "").strip().lower() or None + if normalized_status == PDF_STATUS_UNTRACKED: + result = await db_session.execute( + _untracked_queue_select( + limit=bounded_limit, + offset=bounded_offset, + ) + ) + return await _hydrated_queue_items(db_session, rows=list(result.all())) + if normalized_status is None: + result = await db_session.execute( + _all_queue_select( + limit=bounded_limit, + offset=bounded_offset, + ) + ) + return await _hydrated_queue_items(db_session, rows=list(result.all())) + result = await db_session.execute( + _tracked_queue_select( + limit=bounded_limit, + offset=bounded_offset, + status=normalized_status, + ) + ) + return await _hydrated_queue_items(db_session, rows=list(result.all())) + + +async def count_pdf_queue_items( + db_session: AsyncSession, + *, + status: str | None = None, +) -> int: + normalized_status = (status or "").strip().lower() or None + if normalized_status == PDF_STATUS_UNTRACKED: + result = await db_session.execute(_untracked_queue_count_select()) + return int(result.scalar_one() or 0) + tracked_result = await db_session.execute(_tracked_queue_count_select(status=normalized_status)) + tracked_count = int(tracked_result.scalar_one() or 0) + if normalized_status is not None: + return tracked_count + untracked_result = await db_session.execute(_untracked_queue_count_select()) + untracked_count = int(untracked_result.scalar_one() or 0) + return tracked_count + untracked_count + + +async def list_pdf_queue_page( + db_session: AsyncSession, + *, + limit: int = 100, + offset: int = 0, + status: str | None = None, +) -> PdfQueuePage: + bounded_limit = _bounded_limit(limit) + bounded_offset = _bounded_offset(offset) + items = await list_pdf_queue_items( + db_session, + limit=bounded_limit, + offset=bounded_offset, + status=status, + ) + total_count = await count_pdf_queue_items( + db_session, + status=status, + ) + return PdfQueuePage( + items=items, + total_count=total_count, + limit=bounded_limit, + offset=bounded_offset, + ) diff --git a/app/services/publications/pdf_queue_resolution.py b/app/services/publications/pdf_queue_resolution.py new file mode 100644 index 0000000..ec4422e --- /dev/null +++ b/app/services/publications/pdf_queue_resolution.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import asyncio +import logging + +from app.db.models import Publication, PublicationPdfJob +from app.db.session import get_session_factory +from app.logging_utils import structured_log +from app.services.publication_identifiers import application as identifier_service +from app.services.publications.pdf_queue_common import ( + PDF_STATUS_FAILED, + PDF_STATUS_RESOLVED, + PDF_STATUS_RUNNING, + event_row, + queued_job, + utcnow, +) +from app.services.publications.pdf_resolution_pipeline import ( + resolve_publication_pdf_outcome_for_row, +) +from app.services.publications.types import PublicationListItem +from app.services.unpaywall.application import ( + FAILURE_RESOLUTION_EXCEPTION, + OaResolutionOutcome, +) +from app.settings import settings + +PDF_EVENT_ATTEMPT_STARTED = "attempt_started" +PDF_EVENT_RESOLVED = "resolved" +PDF_EVENT_FAILED = "failed" + +logger = logging.getLogger(__name__) +_scheduled_tasks: set[asyncio.Task[None]] = set() + + +async def _mark_attempt_started( + *, + publication_id: int, + user_id: int, +) -> None: + session_factory = get_session_factory() + async with session_factory() as db_session: + job = await db_session.get(PublicationPdfJob, publication_id) + if job is None: + job = queued_job(publication_id=publication_id, user_id=user_id) + db_session.add(job) + job.status = PDF_STATUS_RUNNING + job.last_attempt_at = utcnow() + job.attempt_count = int(job.attempt_count or 0) + 1 + db_session.add( + event_row( + publication_id=publication_id, + user_id=user_id, + event_type=PDF_EVENT_ATTEMPT_STARTED, + status=PDF_STATUS_RUNNING, + ) + ) + await db_session.commit() + + +def _failed_outcome( + *, + row: PublicationListItem, +) -> OaResolutionOutcome: + return OaResolutionOutcome( + publication_id=row.publication_id, + doi=None, + pdf_url=None, + failure_reason=FAILURE_RESOLUTION_EXCEPTION, + source=None, + used_crossref=False, + ) + + +async def _fetch_outcome_for_row( + *, + row: PublicationListItem, + request_email: str | None, + openalex_api_key: str | None = None, + allow_arxiv_lookup: bool = True, +) -> tuple[OaResolutionOutcome, bool]: + pipeline_result = await resolve_publication_pdf_outcome_for_row( + row=row, + request_email=request_email, + openalex_api_key=openalex_api_key, + allow_arxiv_lookup=allow_arxiv_lookup, + ) + outcome = pipeline_result.outcome + if outcome is not None: + return outcome, bool(pipeline_result.arxiv_rate_limited) + return _failed_outcome(row=row), bool(pipeline_result.arxiv_rate_limited) + + +def _apply_publication_update( + publication: Publication, + *, + pdf_url: str | None, +) -> None: + if pdf_url and publication.pdf_url != pdf_url: + publication.pdf_url = pdf_url + + +def _apply_job_outcome(job: PublicationPdfJob, *, outcome: OaResolutionOutcome) -> None: + job.last_source = outcome.source + if outcome.pdf_url: + job.status = PDF_STATUS_RESOLVED + job.resolved_at = utcnow() + job.last_failure_reason = None + job.last_failure_detail = None + return + job.status = PDF_STATUS_FAILED + job.last_failure_reason = outcome.failure_reason + job.last_failure_detail = outcome.failure_reason + + +def _result_event(outcome: OaResolutionOutcome) -> tuple[str, str]: + if outcome.pdf_url: + return PDF_EVENT_RESOLVED, PDF_STATUS_RESOLVED + return PDF_EVENT_FAILED, PDF_STATUS_FAILED + + +async def _persist_outcome( + *, + publication_id: int, + user_id: int, + outcome: OaResolutionOutcome, +) -> None: + session_factory = get_session_factory() + async with session_factory() as db_session: + publication = await db_session.get(Publication, publication_id) + job = await db_session.get(PublicationPdfJob, publication_id) + if publication is None or job is None: + return + _apply_publication_update(publication, pdf_url=outcome.pdf_url) + await identifier_service.sync_identifiers_for_publication_resolution( + db_session, + publication=publication, + source=outcome.source, + ) + _apply_job_outcome(job, outcome=outcome) + event_type, status = _result_event(outcome) + db_session.add( + event_row( + publication_id=publication_id, + user_id=user_id, + event_type=event_type, + status=status, + source=outcome.source, + failure_reason=outcome.failure_reason, + message=outcome.failure_reason, + ) + ) + await db_session.commit() + + +async def _resolve_publication_row( + *, + user_id: int, + request_email: str | None, + row: PublicationListItem, + openalex_api_key: str | None = None, + allow_arxiv_lookup: bool = True, +) -> bool: + from app.services.openalex.client import OpenAlexBudgetExhaustedError + + await _mark_attempt_started(publication_id=row.publication_id, user_id=user_id) + try: + outcome, arxiv_rate_limited = await _fetch_outcome_for_row( + row=row, + request_email=request_email, + openalex_api_key=openalex_api_key, + allow_arxiv_lookup=allow_arxiv_lookup, + ) + except OpenAlexBudgetExhaustedError: + await _persist_outcome( + publication_id=row.publication_id, + user_id=user_id, + outcome=_failed_outcome(row=row), + ) + raise + except Exception as exc: # pragma: no cover - defensive network boundary + structured_log( + logger, + "warning", + "publications.pdf_queue.resolve_failed", + publication_id=row.publication_id, + error=str(exc), + ) + outcome = _failed_outcome(row=row) + arxiv_rate_limited = False + await _persist_outcome( + publication_id=row.publication_id, + user_id=user_id, + outcome=outcome, + ) + return bool(arxiv_rate_limited) + + +async def _run_resolution_task( + *, + user_id: int, + request_email: str | None, + rows: list[PublicationListItem], +) -> None: + from app.services.openalex.client import OpenAlexBudgetExhaustedError + from app.services.settings import application as user_settings_service + + openalex_api_key: str | None = None + try: + session_factory = get_session_factory() + async with session_factory() as key_session: + user_settings = await user_settings_service.get_or_create_settings(key_session, user_id=user_id) + openalex_api_key = getattr(user_settings, "openalex_api_key", None) or settings.openalex_api_key + except Exception: + openalex_api_key = settings.openalex_api_key + + arxiv_lookup_allowed = True + for row in rows: + try: + arxiv_rate_limited = await _resolve_publication_row( + user_id=user_id, + request_email=request_email, + row=row, + openalex_api_key=openalex_api_key, + allow_arxiv_lookup=arxiv_lookup_allowed, + ) + if arxiv_rate_limited and arxiv_lookup_allowed: + arxiv_lookup_allowed = False + structured_log( + logger, + "warning", + "pdf_queue.arxiv_batch_disabled", + detail="arXiv temporarily disabled for remaining batch after rate limit", + ) + except OpenAlexBudgetExhaustedError: + structured_log( + logger, + "warning", + "pdf_queue.budget_exhausted", + detail="Stopping PDF resolution batch — OpenAlex daily budget exhausted", + ) + break + except Exception: + structured_log( + logger, + "exception", + "pdf_queue.row_failed", + publication_id=row.publication_id, + ) + try: + await _persist_outcome( + publication_id=row.publication_id, + user_id=user_id, + outcome=_failed_outcome(row=row), + ) + except Exception: + structured_log( + logger, + "exception", + "pdf_queue.row_fail_persist_error", + publication_id=row.publication_id, + ) + + +def _register_task(task: asyncio.Task[None]) -> None: + _scheduled_tasks.add(task) + + +def _drop_finished_task(task: asyncio.Task[None]) -> None: + _scheduled_tasks.discard(task) + try: + task.result() + except Exception: + structured_log(logger, "exception", "publications.pdf_queue.task_failed") + + +def schedule_rows( + *, + user_id: int, + request_email: str | None, + rows: list[PublicationListItem], +) -> None: + if not rows: + return + task = asyncio.create_task( + _run_resolution_task( + user_id=user_id, + request_email=request_email, + rows=rows, + ) + ) + _register_task(task) + task.add_done_callback(_drop_finished_task) diff --git a/app/services/publications/pdf_resolution_pipeline.py b/app/services/publications/pdf_resolution_pipeline.py new file mode 100644 index 0000000..b36777d --- /dev/null +++ b/app/services/publications/pdf_resolution_pipeline.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +from app.logging_utils import structured_log +from app.services.arxiv.errors import ArxivRateLimitError +from app.services.arxiv.guards import arxiv_skip_reason_for_item +from app.services.openalex.client import OpenAlexBudgetExhaustedError +from app.services.publications.types import PublicationListItem +from app.services.unpaywall.application import OaResolutionOutcome, resolve_publication_oa_outcomes +from app.settings import settings + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class PipelineOutcome: + outcome: OaResolutionOutcome | None + scholar_candidates: Any | None # Kept for backward compatibility with calling signatures + arxiv_rate_limited: bool = False + + +async def resolve_publication_pdf_outcome_for_row( + *, + row: PublicationListItem, + request_email: str | None, + openalex_api_key: str | None = None, + allow_arxiv_lookup: bool = True, +) -> PipelineOutcome: + # 1. OpenAlex OA — raises OpenAlexBudgetExhaustedError if budget is gone + openalex_outcome = await _openalex_outcome(row, request_email=request_email, openalex_api_key=openalex_api_key) + if openalex_outcome and openalex_outcome.pdf_url: + return PipelineOutcome(openalex_outcome, None) + + # 2. arXiv + arxiv_rate_limited = False + try: + arxiv_outcome = await _arxiv_outcome( + row, + request_email=request_email, + allow_lookup=allow_arxiv_lookup, + ) + except ArxivRateLimitError: + arxiv_rate_limited = True + arxiv_outcome = None + structured_log(logger, "warning", "pdf_resolution.arxiv_rate_limited", publication_id=int(row.publication_id)) + if arxiv_outcome and arxiv_outcome.pdf_url: + return PipelineOutcome(arxiv_outcome, None, arxiv_rate_limited=arxiv_rate_limited) + + # 3. Unpaywall (which falls back to Crossref) + oa_outcome = await _oa_outcome(row=row, request_email=request_email) + return PipelineOutcome(oa_outcome, None, arxiv_rate_limited=arxiv_rate_limited) + + +async def _openalex_outcome( + row: PublicationListItem, + request_email: str | None, + openalex_api_key: str | None = None, +) -> OaResolutionOutcome | None: + from app.services.openalex.client import OpenAlexClient + from app.services.openalex.matching import find_best_match + + if not row.title: + return None + + import re + + safe_title = re.sub(r"[^\w\s]", " ", row.title) + safe_title = " ".join(safe_title.split()) + if not safe_title: + return None + + api_key = openalex_api_key or settings.openalex_api_key + client = OpenAlexClient(api_key=api_key, mailto=request_email or settings.crossref_api_mailto) + try: + openalex_works = await client.get_works_by_filter({"title.search": safe_title}, limit=5) + match = find_best_match( + target_title=row.title, + target_year=row.year, + target_authors=row.scholar_label, + candidates=openalex_works, + ) + if match and match.oa_url: + return OaResolutionOutcome( + publication_id=row.publication_id, + doi=match.doi, + pdf_url=match.oa_url, + failure_reason=None, + source="openalex", + used_crossref=False, + ) + except OpenAlexBudgetExhaustedError: + # Re-raise so the caller's batch loop can stop hitting the API. + raise + except Exception as exc: + structured_log(logger, "warning", "pdf_resolution.openalex_failed", error=str(exc)) + return None + + +async def _arxiv_outcome( + row: PublicationListItem, + *, + request_email: str | None, + allow_lookup: bool = True, +) -> OaResolutionOutcome | None: + from app.services.arxiv.application import discover_arxiv_id_for_publication + + if not allow_lookup: + structured_log( + logger, + "info", + "pdf_resolution.arxiv_skipped", + publication_id=int(row.publication_id), + skip_reason="batch_arxiv_cooldown_active", + ) + return None + + skip_reason = arxiv_skip_reason_for_item(item=row) + if skip_reason is not None: + structured_log( + logger, + "info", + "pdf_resolution.arxiv_skipped", + publication_id=int(row.publication_id), + skip_reason=skip_reason, + ) + return None + + try: + arxiv_id = await discover_arxiv_id_for_publication(item=row, request_email=request_email) + if arxiv_id: + pdf_url = f"https://arxiv.org/pdf/{arxiv_id}.pdf" + return OaResolutionOutcome( + publication_id=row.publication_id, + doi=None, + pdf_url=pdf_url, + failure_reason=None, + source="arxiv", + used_crossref=False, + ) + except ArxivRateLimitError: + raise # propagate so orchestration can switch to non-arXiv fallback + except Exception as exc: + structured_log(logger, "warning", "pdf_resolution.arxiv_failed", error=str(exc)) + return None + + +async def _oa_outcome( + *, + row: PublicationListItem, + request_email: str | None, +) -> OaResolutionOutcome | None: + outcomes = await resolve_publication_oa_outcomes([row], request_email=request_email) + return outcomes.get(row.publication_id) diff --git a/app/services/domains/publications/queries.py b/app/services/publications/queries.py similarity index 63% rename from app/services/domains/publications/queries.py rename to app/services/publications/queries.py index 292ec8e..f113080 100644 --- a/app/services/domains/publications/queries.py +++ b/app/services/publications/queries.py @@ -1,29 +1,78 @@ from __future__ import annotations -from sqlalchemy import Select, func, select +from datetime import datetime +from typing import Any + +from sqlalchemy import Select, case, func, select +from sqlalchemy import false as sa_false from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import CrawlRun, Publication, RunStatus, ScholarProfile, ScholarPublication -from app.services.domains.publications.modes import MODE_LATEST, MODE_UNREAD -from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem +from app.db.models import ( + CrawlRun, + Publication, + PublicationPdfJob, + RunStatus, + ScholarProfile, + ScholarPublication, +) +from app.services.publications.modes import MODE_LATEST, MODE_UNREAD +from app.services.publications.pdf_queue_common import ( + PDF_STATUS_FAILED, + PDF_STATUS_QUEUED, + PDF_STATUS_RESOLVED, + PDF_STATUS_RUNNING, +) +from app.services.publications.types import PublicationListItem, UnreadPublicationItem def _normalized_citation_count(value: object) -> int: try: - return int(value or 0) + return int(value or 0) # type: ignore[call-overload] # intentionally accepts any object except (TypeError, ValueError): return 0 -async def get_latest_completed_run_id_for_user( +def _pdf_status_sort_rank(): + return case( + (Publication.pdf_url.is_not(None), 4), + (PublicationPdfJob.status == PDF_STATUS_RESOLVED, 4), + (PublicationPdfJob.status == PDF_STATUS_RUNNING, 3), + (PublicationPdfJob.status == PDF_STATUS_QUEUED, 2), + (PublicationPdfJob.status == PDF_STATUS_FAILED, 0), + else_=1, + ) + + +def _sort_column(sort_by: str): + sort_columns = { + "first_seen": ScholarPublication.created_at, + "title": Publication.title_raw, + "year": Publication.year, + "citations": Publication.citation_count, + "scholar": ScholarProfile.display_name, + "pdf_status": _pdf_status_sort_rank(), + } + return sort_columns.get(sort_by, ScholarPublication.created_at) + + +async def get_latest_run_id_for_user( db_session: AsyncSession, *, user_id: int, ) -> int | None: + # We include RUNNING and RESOLVING statuses so that the "New" tab shows + # results in real-time as they are discovered. result = await db_session.execute( select(func.max(CrawlRun.id)).where( CrawlRun.user_id == user_id, - CrawlRun.status != RunStatus.RUNNING, + CrawlRun.status.in_( + [ + RunStatus.RUNNING, + RunStatus.RESOLVING, + RunStatus.SUCCESS, + RunStatus.PARTIAL_FAILURE, + ] + ), ) ) latest_run_id = result.scalar_one_or_none() @@ -39,6 +88,10 @@ def publications_query( favorite_only: bool, limit: int, offset: int = 0, + search: str | None = None, + sort_by: str = "first_seen", + sort_dir: str = "desc", + snapshot_before: datetime | None = None, ) -> Select[tuple]: scholar_label = ScholarProfile.display_name stmt = ( @@ -52,7 +105,6 @@ def publications_query( Publication.citation_count, Publication.venue_text, Publication.pub_url, - Publication.doi, Publication.pdf_url, ScholarPublication.is_read, ScholarPublication.is_favorite, @@ -61,11 +113,17 @@ def publications_query( ) .join(ScholarPublication, ScholarPublication.publication_id == Publication.id) .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) + .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) .where(ScholarProfile.user_id == user_id) - .order_by(ScholarPublication.created_at.desc(), Publication.id.desc()) - .offset(max(int(offset), 0)) - .limit(limit) ) + if search: + safe_search = search.replace("%", r"\%").replace("_", r"\_") + pat = f"%{safe_search}%" + stmt = stmt.where( + Publication.title_raw.ilike(pat) + | ScholarProfile.display_name.ilike(pat) + | Publication.venue_text.ilike(pat) + ) if scholar_profile_id is not None: stmt = stmt.where(ScholarProfile.id == scholar_profile_id) if favorite_only: @@ -74,8 +132,18 @@ def publications_query( stmt = stmt.where(ScholarPublication.is_read.is_(False)) if mode == MODE_LATEST: if latest_run_id is None: - return stmt.where(False) + return stmt.where(sa_false()) stmt = stmt.where(ScholarPublication.first_seen_run_id == latest_run_id) + if snapshot_before is not None: + stmt = stmt.where(ScholarPublication.created_at <= snapshot_before) + + sort_col = _sort_column(sort_by) + order = sort_col.desc() if sort_dir == "desc" else sort_col.asc() + stmt = stmt.order_by(order, Publication.id.desc()) + + if limit is not None: + stmt = stmt.offset(max(int(offset), 0)).limit(limit) + return stmt @@ -96,7 +164,6 @@ def publication_query_for_user( Publication.citation_count, Publication.venue_text, Publication.pub_url, - Publication.doi, Publication.pdf_url, ScholarPublication.is_read, ScholarPublication.is_favorite, @@ -121,7 +188,7 @@ async def get_publication_item_for_user( scholar_profile_id: int, publication_id: int, ) -> PublicationListItem | None: - latest_run_id = await get_latest_completed_run_id_for_user(db_session, user_id=user_id) + latest_run_id = await get_latest_run_id_for_user(db_session, user_id=user_id) result = await db_session.execute( publication_query_for_user( user_id=user_id, @@ -136,7 +203,7 @@ async def get_publication_item_for_user( def publication_list_item_from_row( - row: tuple, + row: Any, *, latest_run_id: int | None, ) -> PublicationListItem: @@ -150,7 +217,6 @@ def publication_list_item_from_row( citation_count, venue_text, pub_url, - doi, pdf_url, is_read, is_favorite, @@ -166,18 +232,15 @@ def publication_list_item_from_row( citation_count=_normalized_citation_count(citation_count), venue_text=venue_text, pub_url=pub_url, - doi=doi, pdf_url=pdf_url, is_read=bool(is_read), is_favorite=bool(is_favorite), first_seen_at=created_at, - is_new_in_latest_run=( - latest_run_id is not None and int(first_seen_run_id or 0) == latest_run_id - ), + is_new_in_latest_run=(latest_run_id is not None and int(first_seen_run_id or 0) == latest_run_id), ) -def unread_item_from_row(row: tuple) -> UnreadPublicationItem: +def unread_item_from_row(row: Any) -> UnreadPublicationItem: ( publication_id, scholar_profile_id, @@ -188,7 +251,6 @@ def unread_item_from_row(row: tuple) -> UnreadPublicationItem: citation_count, venue_text, pub_url, - doi, pdf_url, _is_read, _is_favorite, @@ -204,6 +266,5 @@ def unread_item_from_row(row: tuple) -> UnreadPublicationItem: citation_count=_normalized_citation_count(citation_count), venue_text=venue_text, pub_url=pub_url, - doi=doi, pdf_url=pdf_url, ) diff --git a/app/services/domains/publications/read_state.py b/app/services/publications/read_state.py similarity index 84% rename from app/services/domains/publications/read_state.py rename to app/services/publications/read_state.py index 41fc31a..2a56b61 100644 --- a/app/services/domains/publications/read_state.py +++ b/app/services/publications/read_state.py @@ -1,6 +1,8 @@ from __future__ import annotations -from sqlalchemy import select, tuple_, update +from typing import Any + +from sqlalchemy import CursorResult, select, tuple_, update from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import ScholarProfile, ScholarPublication @@ -17,11 +19,7 @@ def _normalized_selection_pairs(selections: list[tuple[int, int]]) -> set[tuple[ def _scoped_scholar_ids_query(*, user_id: int): - return ( - select(ScholarProfile.id) - .where(ScholarProfile.user_id == user_id) - .scalar_subquery() - ) + return select(ScholarProfile.id).where(ScholarProfile.user_id == user_id).scalar_subquery() async def mark_all_unread_as_read_for_user( @@ -38,7 +36,7 @@ async def mark_all_unread_as_read_for_user( ) .values(is_read=True) ) - result = await db_session.execute(stmt) + result: CursorResult[Any] = await db_session.execute(stmt) # type: ignore[assignment] await db_session.commit() return int(result.rowcount or 0) @@ -66,7 +64,7 @@ async def mark_selected_as_read_for_user( ) .values(is_read=True) ) - result = await db_session.execute(stmt) + result: CursorResult[Any] = await db_session.execute(stmt) # type: ignore[assignment] await db_session.commit() return int(result.rowcount or 0) @@ -89,6 +87,6 @@ async def set_publication_favorite_for_user( ) .values(is_favorite=bool(is_favorite)) ) - result = await db_session.execute(stmt) + result: CursorResult[Any] = await db_session.execute(stmt) # type: ignore[assignment] await db_session.commit() return int(result.rowcount or 0) diff --git a/app/services/domains/publications/types.py b/app/services/publications/types.py similarity index 87% rename from app/services/domains/publications/types.py rename to app/services/publications/types.py index d1d4bad..c8b1ad1 100644 --- a/app/services/domains/publications/types.py +++ b/app/services/publications/types.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime +from app.services.publication_identifiers.types import DisplayIdentifier + @dataclass(frozen=True) class PublicationListItem: @@ -14,7 +16,6 @@ class PublicationListItem: citation_count: int venue_text: str | None pub_url: str | None - doi: str | None pdf_url: str | None is_read: bool first_seen_at: datetime @@ -24,6 +25,7 @@ class PublicationListItem: pdf_attempt_count: int = 0 pdf_failure_reason: str | None = None pdf_failure_detail: str | None = None + display_identifier: DisplayIdentifier | None = None @dataclass(frozen=True) @@ -36,5 +38,4 @@ class UnreadPublicationItem: citation_count: int venue_text: str | None pub_url: str | None - doi: str | None pdf_url: str | None diff --git a/app/services/runs/__init__.py b/app/services/runs/__init__.py new file mode 100644 index 0000000..5bed6a2 --- /dev/null +++ b/app/services/runs/__init__.py @@ -0,0 +1,51 @@ +from app.services.runs.application import ( + QUEUE_STATUS_DROPPED as QUEUE_STATUS_DROPPED, +) +from app.services.runs.application import ( + QUEUE_STATUS_QUEUED as QUEUE_STATUS_QUEUED, +) +from app.services.runs.application import ( + QUEUE_STATUS_RETRYING as QUEUE_STATUS_RETRYING, +) +from app.services.runs.application import ( + QueueClearResult as QueueClearResult, +) +from app.services.runs.application import ( + QueueListItem as QueueListItem, +) +from app.services.runs.application import ( + QueueTransitionError as QueueTransitionError, +) +from app.services.runs.application import ( + clear_queue_item_for_user as clear_queue_item_for_user, +) +from app.services.runs.application import ( + drop_queue_item_for_user as drop_queue_item_for_user, +) +from app.services.runs.application import ( + extract_run_summary as extract_run_summary, +) +from app.services.runs.application import ( + get_manual_run_by_idempotency_key as get_manual_run_by_idempotency_key, +) +from app.services.runs.application import ( + get_queue_item_for_user as get_queue_item_for_user, +) +from app.services.runs.application import ( + get_run_for_user as get_run_for_user, +) +from app.services.runs.application import ( + list_queue_items_for_user as list_queue_items_for_user, +) +from app.services.runs.application import ( + list_recent_runs_for_user as list_recent_runs_for_user, +) +from app.services.runs.application import ( + list_runs_for_user as list_runs_for_user, +) +from app.services.runs.application import ( + queue_status_counts_for_user as queue_status_counts_for_user, +) +from app.services.runs.application import ( + retry_queue_item_for_user as retry_queue_item_for_user, +) diff --git a/app/services/domains/runs/application.py b/app/services/runs/application.py similarity index 82% rename from app/services/domains/runs/application.py rename to app/services/runs/application.py index 0019446..e0b464a 100644 --- a/app/services/domains/runs/application.py +++ b/app/services/runs/application.py @@ -1,6 +1,6 @@ from __future__ import annotations -from app.services.domains.runs.queue_service import ( +from app.services.runs.queue_service import ( clear_queue_item_for_user, drop_queue_item_for_user, get_queue_item_for_user, @@ -8,14 +8,14 @@ from app.services.domains.runs.queue_service import ( queue_status_counts_for_user, retry_queue_item_for_user, ) -from app.services.domains.runs.runs_service import ( +from app.services.runs.runs_service import ( get_manual_run_by_idempotency_key, get_run_for_user, list_recent_runs_for_user, list_runs_for_user, ) -from app.services.domains.runs.summary import extract_run_summary -from app.services.domains.runs.types import ( +from app.services.runs.summary import extract_run_summary +from app.services.runs.types import ( QUEUE_STATUS_DROPPED, QUEUE_STATUS_QUEUED, QUEUE_STATUS_RETRYING, @@ -25,21 +25,21 @@ from app.services.domains.runs.types import ( ) __all__ = [ + "QUEUE_STATUS_DROPPED", "QUEUE_STATUS_QUEUED", "QUEUE_STATUS_RETRYING", - "QUEUE_STATUS_DROPPED", - "QueueListItem", "QueueClearResult", + "QueueListItem", "QueueTransitionError", + "clear_queue_item_for_user", + "drop_queue_item_for_user", "extract_run_summary", + "get_manual_run_by_idempotency_key", + "get_queue_item_for_user", + "get_run_for_user", + "list_queue_items_for_user", "list_recent_runs_for_user", "list_runs_for_user", - "get_run_for_user", - "get_manual_run_by_idempotency_key", - "list_queue_items_for_user", - "get_queue_item_for_user", - "retry_queue_item_for_user", - "drop_queue_item_for_user", - "clear_queue_item_for_user", "queue_status_counts_for_user", + "retry_queue_item_for_user", ] diff --git a/app/services/runs/events.py b/app/services/runs/events.py new file mode 100644 index 0000000..dbcac2d --- /dev/null +++ b/app/services/runs/events.py @@ -0,0 +1,87 @@ +import asyncio +import json +import logging +from collections.abc import AsyncGenerator +from typing import Any + +from app.logging_utils import structured_log + +logger = logging.getLogger(__name__) + +_SUBSCRIBER_QUEUE_MAXSIZE = 256 + + +class RunEventPublisher: + def __init__(self) -> None: + # Maps run_id to a set of subscriber queues + self._subscribers: dict[int, set[asyncio.Queue]] = {} + + def subscribe(self, run_id: int) -> asyncio.Queue: + if run_id not in self._subscribers: + self._subscribers[run_id] = set() + queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=_SUBSCRIBER_QUEUE_MAXSIZE) + self._subscribers[run_id].add(queue) + structured_log( + logger, + "debug", + "runs.event_subscriber_added", + run_id=run_id, + subscriber_count=len(self._subscribers[run_id]), + ) + return queue + + def unsubscribe(self, run_id: int, queue: asyncio.Queue) -> None: + if run_id in self._subscribers: + self._subscribers[run_id].discard(queue) + if not self._subscribers[run_id]: + self._subscribers.pop(run_id, None) + + async def publish(self, run_id: int, event_type: str, data: dict[str, Any]) -> None: + if run_id not in self._subscribers: + return + + message = {"type": event_type, "data": data} + + # Fan-out to all active subscribers for this run + for queue in list(self._subscribers[run_id]): + try: + queue.put_nowait(message) + except asyncio.QueueFull: + structured_log( + logger, + "warning", + "runs.event_subscriber_queue_full", + run_id=run_id, + ) + self._subscribers[run_id].discard(queue) + + async def publish_run_complete(self, run_id: int) -> None: + await self.publish(run_id, "run_complete", {}) + + +run_events = RunEventPublisher() + + +async def event_generator(run_id: int) -> AsyncGenerator[str, None]: + queue = run_events.subscribe(run_id) + try: + while True: + # Wait for a new event + message = await queue.get() + event_type = message["type"] + if event_type == "run_complete": + yield f"event: {event_type}\ndata: {{}}\n\n" + break + # Server-Sent Events format: "event: \ndata: \n\n" + data_str = json.dumps(message["data"]) + yield f"event: {event_type}\ndata: {data_str}\n\n" + except asyncio.CancelledError: + structured_log( + logger, + "debug", + "runs.event_stream_disconnected", + run_id=run_id, + ) + raise + finally: + run_events.unsubscribe(run_id, queue) diff --git a/app/services/domains/runs/queue_queries.py b/app/services/runs/queue_queries.py similarity index 93% rename from app/services/domains/runs/queue_queries.py rename to app/services/runs/queue_queries.py index 7d2e501..2d1127a 100644 --- a/app/services/domains/runs/queue_queries.py +++ b/app/services/runs/queue_queries.py @@ -1,9 +1,11 @@ from __future__ import annotations +from typing import Any + from sqlalchemy import and_, select from app.db.models import IngestionQueueItem, ScholarProfile -from app.services.domains.runs.types import QueueListItem +from app.services.runs.types import QueueListItem def queue_item_columns() -> tuple: @@ -38,7 +40,7 @@ def queue_item_select(*, user_id: int): ) -def queue_list_item_from_row(row: tuple) -> QueueListItem: +def queue_list_item_from_row(row: Any) -> QueueListItem: ( item_id, scholar_profile_id, diff --git a/app/services/domains/runs/queue_service.py b/app/services/runs/queue_service.py similarity index 93% rename from app/services/domains/runs/queue_service.py rename to app/services/runs/queue_service.py index d69ce21..855171f 100644 --- a/app/services/domains/runs/queue_service.py +++ b/app/services/runs/queue_service.py @@ -4,9 +4,9 @@ from sqlalchemy import case, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import IngestionQueueItem -from app.services.domains.ingestion import queue as queue_mutations -from app.services.domains.runs.queue_queries import queue_item_select, queue_list_item_from_row -from app.services.domains.runs.types import ( +from app.services.ingestion import queue as queue_mutations +from app.services.runs.queue_queries import queue_item_select, queue_list_item_from_row +from app.services.runs.types import ( QUEUE_STATUS_DROPPED, QUEUE_STATUS_QUEUED, QUEUE_STATUS_RETRYING, @@ -41,9 +41,7 @@ async def get_queue_item_for_user( queue_item_id: int, ) -> QueueListItem | None: result = await db_session.execute( - queue_item_select(user_id=user_id) - .where(IngestionQueueItem.id == queue_item_id) - .limit(1) + queue_item_select(user_id=user_id).where(IngestionQueueItem.id == queue_item_id).limit(1) ) row = result.one_or_none() if row is None: diff --git a/app/services/domains/runs/runs_service.py b/app/services/runs/runs_service.py similarity index 94% rename from app/services/domains/runs/runs_service.py rename to app/services/runs/runs_service.py index 1cb9aa2..72901ec 100644 --- a/app/services/domains/runs/runs_service.py +++ b/app/services/runs/runs_service.py @@ -35,9 +35,7 @@ async def list_runs_for_user( .limit(limit) ) if failed_only: - stmt = stmt.where( - CrawlRun.status.in_([RunStatus.FAILED, RunStatus.PARTIAL_FAILURE]) - ) + stmt = stmt.where(CrawlRun.status.in_([RunStatus.FAILED, RunStatus.PARTIAL_FAILURE])) result = await db_session.execute(stmt) return list(result.scalars().all()) diff --git a/app/services/domains/runs/summary.py b/app/services/runs/summary.py similarity index 86% rename from app/services/domains/runs/summary.py rename to app/services/runs/summary.py index 9555558..58cb7e3 100644 --- a/app/services/domains/runs/summary.py +++ b/app/services/runs/summary.py @@ -1,11 +1,11 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast def _safe_int(value: object, default: int = 0) -> int: try: - return int(value) + return int(cast(Any, value)) except (TypeError, ValueError): return default @@ -24,9 +24,7 @@ def _summary_int_dict(summary: dict[str, Any], key: str) -> dict[str, int]: if not isinstance(value, dict): return {} return { - str(item_key): _safe_int(item_value, 0) - for item_key, item_value in value.items() - if isinstance(item_key, str) + str(item_key): _safe_int(item_value, 0) for item_key, item_value in value.items() if isinstance(item_key, str) } @@ -34,11 +32,7 @@ def _summary_bool_dict(summary: dict[str, Any], key: str) -> dict[str, bool]: value = summary.get(key) if not isinstance(value, dict): return {} - return { - str(item_key): bool(item_value) - for item_key, item_value in value.items() - if isinstance(item_key, str) - } + return {str(item_key): bool(item_value) for item_key, item_value in value.items() if isinstance(item_key, str)} def _retry_counts(summary: dict[str, Any]) -> dict[str, int]: diff --git a/app/services/domains/runs/types.py b/app/services/runs/types.py similarity index 100% rename from app/services/domains/runs/types.py rename to app/services/runs/types.py diff --git a/app/services/domains/ingestion/__init__.py b/app/services/scholar/__init__.py similarity index 100% rename from app/services/domains/ingestion/__init__.py rename to app/services/scholar/__init__.py diff --git a/app/services/domains/scholar/author_rows.py b/app/services/scholar/author_rows.py similarity index 92% rename from app/services/domains/scholar/author_rows.py rename to app/services/scholar/author_rows.py index 8acd783..a0705fb 100644 --- a/app/services/domains/scholar/author_rows.py +++ b/app/services/scholar/author_rows.py @@ -5,9 +5,9 @@ from html.parser import HTMLParser from typing import Any from urllib.parse import parse_qs, urlparse -from app.services.domains.scholar.parser_constants import AUTHOR_SEARCH_MARKER_KEYS -from app.services.domains.scholar.parser_types import ScholarSearchCandidate -from app.services.domains.scholar.parser_utils import ( +from app.services.scholar.parser_constants import AUTHOR_SEARCH_MARKER_KEYS +from app.services.scholar.parser_types import ScholarSearchCandidate +from app.services.scholar.parser_utils import ( attr_class, attr_href, attr_src, @@ -79,9 +79,7 @@ class ScholarAuthorSearchParser(HTMLParser): return affiliation = normalize_space("".join(self._candidate["aff_parts"])) or None - email_domain = _extract_verified_email_domain( - normalize_space("".join(self._candidate["eml_parts"])) or None - ) + email_domain = _extract_verified_email_domain(normalize_space("".join(self._candidate["eml_parts"])) or None) cited_by_text = normalize_space("".join(self._candidate["cby_parts"])) cited_by_match = re.search(r"\d+", cited_by_text) cited_by_count = int(cited_by_match.group(0)) if cited_by_match else None @@ -97,10 +95,7 @@ class ScholarAuthorSearchParser(HTMLParser): profile_url = build_absolute_scholar_url(self._candidate["name_href"]) if not profile_url: - profile_url = ( - "https://scholar.google.com/citations" - f"?hl=en&user={scholar_id}" - ) + profile_url = f"https://scholar.google.com/citations?hl=en&user={scholar_id}" self.candidates.append( ScholarSearchCandidate( diff --git a/app/services/domains/scholar/parser.py b/app/services/scholar/parser.py similarity index 88% rename from app/services/domains/scholar/parser.py rename to app/services/scholar/parser.py index 060e825..1718b3f 100644 --- a/app/services/domains/scholar/parser.py +++ b/app/services/scholar/parser.py @@ -1,42 +1,40 @@ from __future__ import annotations -from app.services.domains.scholar.author_rows import ( +from app.services.scholar.author_rows import ( ScholarAuthorSearchParser, count_author_search_markers, - parse_scholar_id_from_href, ) -from app.services.domains.scholar.parser_constants import SCRIPT_STYLE_RE -from app.services.domains.scholar.parser_types import ( - ParseState, +from app.services.scholar.parser_constants import SCRIPT_STYLE_RE +from app.services.scholar.parser_types import ( ParsedAuthorSearchPage, ParsedProfilePage, PublicationCandidate, ScholarDomInvariantError, ScholarMalformedDataError, - ScholarParserError, - ScholarSearchCandidate, ) -from app.services.domains.scholar.parser_utils import ( +from app.services.scholar.parser_types import ( + ParseState as ParseState, +) +from app.services.scholar.parser_types import ( + ScholarParserError as ScholarParserError, +) +from app.services.scholar.parser_types import ( + ScholarSearchCandidate as ScholarSearchCandidate, +) +from app.services.scholar.parser_utils import ( strip_tags, ) -from app.services.domains.scholar.profile_rows import ( - ScholarRowParser, +from app.services.scholar.profile_rows import ( count_markers, extract_articles_range, extract_profile_image_url, extract_profile_name, - extract_rows, has_operation_error_banner, has_show_more_button, - parse_citation_count, - parse_cluster_id_from_href, parse_publications, - parse_year, ) -from app.services.domains.scholar.source import FetchResult -from app.services.domains.scholar.state_detection import ( - classify_block_or_captcha_reason, - classify_network_error_reason, +from app.services.scholar.source import FetchResult +from app.services.scholar.state_detection import ( detect_author_search_state, detect_state, ) diff --git a/app/services/domains/scholar/parser_constants.py b/app/services/scholar/parser_constants.py similarity index 100% rename from app/services/domains/scholar/parser_constants.py rename to app/services/scholar/parser_constants.py diff --git a/app/services/domains/scholar/parser_types.py b/app/services/scholar/parser_types.py similarity index 100% rename from app/services/domains/scholar/parser_types.py rename to app/services/scholar/parser_types.py diff --git a/app/services/domains/scholar/parser_utils.py b/app/services/scholar/parser_utils.py similarity index 93% rename from app/services/domains/scholar/parser_utils.py rename to app/services/scholar/parser_utils.py index 1bf8687..0bc3b77 100644 --- a/app/services/domains/scholar/parser_utils.py +++ b/app/services/scholar/parser_utils.py @@ -2,7 +2,7 @@ from __future__ import annotations from html import unescape -from app.services.domains.scholar.parser_constants import TAG_RE +from app.services.scholar.parser_constants import TAG_RE def normalize_space(value: str) -> str: diff --git a/app/services/domains/scholar/profile_rows.py b/app/services/scholar/profile_rows.py similarity index 94% rename from app/services/domains/scholar/profile_rows.py rename to app/services/scholar/profile_rows.py index 6a63274..d291dbf 100644 --- a/app/services/domains/scholar/profile_rows.py +++ b/app/services/scholar/profile_rows.py @@ -2,17 +2,17 @@ from __future__ import annotations import re from html.parser import HTMLParser +from typing import Any from urllib.parse import parse_qs, urlparse -from app.services.domains.scholar.parser_constants import ( +from app.services.scholar.parser_constants import ( MARKER_KEYS, SHOW_MORE_BUTTON_RE, ) -from app.services.domains.scholar.parser_types import PublicationCandidate -from app.services.domains.scholar.parser_utils import ( +from app.services.scholar.parser_types import PublicationCandidate +from app.services.scholar.parser_utils import ( attr_class, attr_href, - attr_src, build_absolute_scholar_url, normalize_space, strip_tags, @@ -31,7 +31,7 @@ class ScholarRowParser(HTMLParser): self._title_depth = 0 self._citation_depth = 0 self._year_depth = 0 - self._gray_stack: list[dict[str, object]] = [] + self._gray_stack: list[dict[str, Any]] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: if self._title_depth > 0: @@ -131,7 +131,10 @@ def parse_citation_count(parts: list[str]) -> int | None: text = normalize_space(" ".join(parts)) if not text: return 0 - digits = re.sub(r"\D+", "", text) + match = re.search(r"[\d,]+", text) + if not match: + return None + digits = match.group(0).replace(",", "") if not digits: return None return int(digits) @@ -253,14 +256,12 @@ def has_show_more_button(html: str) -> bool: return False if 'aria-disabled="true"' in button_tag or "aria-disabled='true'" in button_tag: return False - if "gs_dis" in button_tag: - return False - return True + return "gs_dis" not in button_tag def has_operation_error_banner(html: str) -> bool: lowered = html.lower() - if "id=\"gsc_a_err\"" not in lowered and "id='gsc_a_err'" not in lowered: + if 'id="gsc_a_err"' not in lowered and "id='gsc_a_err'" not in lowered: return False return "can't perform the operation now" in lowered or "cannot perform the operation now" in lowered diff --git a/app/services/scholar/rate_limit.py b/app/services/scholar/rate_limit.py new file mode 100644 index 0000000..fa708b6 --- /dev/null +++ b/app/services/scholar/rate_limit.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import asyncio +import time + +_REQUEST_LOCK = asyncio.Lock() +_LAST_REQUEST_AT = 0.0 + + +def _normalize_interval_seconds(value: float) -> float: + return max(float(value), 0.0) + + +def remaining_scholar_slot_seconds(*, min_interval_seconds: float) -> float: + interval_seconds = _normalize_interval_seconds(min_interval_seconds) + if interval_seconds <= 0: + return 0.0 + elapsed_seconds = time.monotonic() - _LAST_REQUEST_AT + return max(interval_seconds - elapsed_seconds, 0.0) + + +async def wait_for_scholar_slot(*, min_interval_seconds: float) -> None: + global _LAST_REQUEST_AT + interval = _normalize_interval_seconds(min_interval_seconds) + async with _REQUEST_LOCK: + remaining = remaining_scholar_slot_seconds(min_interval_seconds=interval) + if remaining > 0: + await asyncio.sleep(remaining) + _LAST_REQUEST_AT = time.monotonic() + + +def reset_scholar_rate_limit_state_for_tests() -> None: + global _LAST_REQUEST_AT + _LAST_REQUEST_AT = 0.0 diff --git a/app/services/domains/scholar/source.py b/app/services/scholar/source.py similarity index 52% rename from app/services/domains/scholar/source.py rename to app/services/scholar/source.py index 83da165..afafb1c 100644 --- a/app/services/domains/scholar/source.py +++ b/app/services/scholar/source.py @@ -9,22 +9,20 @@ from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen +from app.logging_utils import structured_log +from app.services.scholar import rate_limit as scholar_rate_limit +from app.settings import settings + SCHOLAR_PROFILE_URL = "https://scholar.google.com/citations" DEFAULT_PAGE_SIZE = 100 DEFAULT_USER_AGENTS = [ - ( - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - ), + ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"), ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 " "(KHTML, like Gecko) Version/18.1 Safari/605.1.15" ), - ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) " - "Gecko/20100101 Firefox/131.0" - ), + ("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0"), ] logger = logging.getLogger(__name__) @@ -40,8 +38,7 @@ class FetchResult: class ScholarSource(Protocol): - async def fetch_profile_html(self, scholar_id: str) -> FetchResult: - ... + async def fetch_profile_html(self, scholar_id: str) -> FetchResult: ... async def fetch_profile_page_html( self, @@ -49,16 +46,14 @@ class ScholarSource(Protocol): *, cstart: int, pagesize: int, - ) -> FetchResult: - ... + ) -> FetchResult: ... async def fetch_author_search_html( self, query: str, *, start: int, - ) -> FetchResult: - ... + ) -> FetchResult: ... class LiveScholarSource: @@ -66,10 +61,48 @@ class LiveScholarSource: self, *, timeout_seconds: float = 25.0, + min_interval_seconds: float | None = None, + rotate_user_agents: bool | None = None, user_agents: list[str] | None = None, ) -> None: self._timeout_seconds = timeout_seconds + configured_interval = ( + float(settings.ingestion_min_request_delay_seconds) + if min_interval_seconds is None + else float(min_interval_seconds) + ) + self._min_interval_seconds = max(configured_interval, 0.0) self._user_agents = user_agents or DEFAULT_USER_AGENTS + self._rotate_user_agents = ( + bool(settings.scholar_http_rotate_user_agent) if rotate_user_agents is None else bool(rotate_user_agents) + ) + configured_user_agent = settings.scholar_http_user_agent.strip() + self._configured_user_agent = configured_user_agent or None + self._accept_language = settings.scholar_http_accept_language.strip() or "en-US,en;q=0.9" + self._cookie_header = settings.scholar_http_cookie.strip() or None + self._stable_user_agent = self._resolve_initial_user_agent() + + def _resolve_initial_user_agent(self) -> str: + if self._configured_user_agent is not None: + return self._configured_user_agent + return random.choice(self._user_agents) + + def _resolve_user_agent_for_request(self) -> str: + if self._configured_user_agent is not None: + return self._configured_user_agent + if self._rotate_user_agents: + return random.choice(self._user_agents) + return self._stable_user_agent + + def _request_headers(self) -> dict[str, str]: + headers = { + "User-Agent": self._resolve_user_agent_for_request(), + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": self._accept_language, + } + if self._cookie_header is not None: + headers["Cookie"] = self._cookie_header + return headers async def fetch_profile_html(self, scholar_id: str) -> FetchResult: return await self.fetch_profile_page_html( @@ -90,17 +123,7 @@ class LiveScholarSource: cstart=cstart, pagesize=pagesize, ) - logger.debug( - "scholar_source.fetch_started", - extra={ - "event": "scholar_source.fetch_started", - "scholar_id": scholar_id, - "requested_url": requested_url, - "cstart": cstart, - "pagesize": pagesize, - }, - ) - return await asyncio.to_thread(self._fetch_sync, requested_url) + return await self._fetch_with_global_throttle(requested_url) async def fetch_author_search_html( self, @@ -112,27 +135,33 @@ class LiveScholarSource: query=query, start=start, ) - logger.debug( - "scholar_source.search_fetch_started", - extra={ - "event": "scholar_source.search_fetch_started", - "query": query, - "requested_url": requested_url, - "start": start, - }, + return await self._fetch_with_global_throttle(requested_url) + + async def fetch_publication_html(self, publication_url: str) -> FetchResult: + return await self._fetch_with_global_throttle(publication_url) + + async def _fetch_with_global_throttle(self, requested_url: str) -> FetchResult: + await scholar_rate_limit.wait_for_scholar_slot( + min_interval_seconds=self._min_interval_seconds, ) return await asyncio.to_thread(self._fetch_sync, requested_url) def _build_request(self, requested_url: str) -> Request: - return Request( - requested_url, - headers={ - "User-Agent": random.choice(self._user_agents), - "Accept": "text/html,application/xhtml+xml", - "Accept-Language": "en-US,en;q=0.9", - "Connection": "close", - }, - ) + return Request(requested_url, headers=self._request_headers()) + + @staticmethod + def _http_error_reason(*, status_code: int, final_url: str, body: str) -> str: + lowered_url = final_url.lower() + lowered_body = body.lower() + if "sorry/index" in lowered_url or "sorry/index" in lowered_body: + return "blocked_google_sorry_challenge" + if "our systems have detected" in lowered_body or "unusual traffic" in lowered_body: + return "blocked_unusual_traffic_detected" + if "automated queries" in lowered_body: + return "blocked_automated_queries_detected" + if status_code == 429: + return "blocked_http_429_rate_limited" + return f"http_error_status_{status_code}" @staticmethod def _http_error_body(exc: HTTPError) -> str: @@ -143,9 +172,11 @@ class LiveScholarSource: @staticmethod def _network_error_result(requested_url: str, exc: URLError) -> FetchResult: - logger.warning( + structured_log( + logger, + "warning", "scholar_source.fetch_network_error", - extra={"event": "scholar_source.fetch_network_error", "requested_url": requested_url}, + requested_url=requested_url, ) return FetchResult( requested_url=requested_url, @@ -157,19 +188,27 @@ class LiveScholarSource: @staticmethod def _http_error_result(requested_url: str, exc: HTTPError) -> FetchResult: - logger.warning( + final_url = exc.geturl() + body = LiveScholarSource._http_error_body(exc) + block_reason = LiveScholarSource._http_error_reason( + status_code=exc.code, + final_url=final_url, + body=body, + ) + structured_log( + logger, + "warning", "scholar_source.fetch_http_error", - extra={ - "event": "scholar_source.fetch_http_error", - "requested_url": requested_url, - "status_code": exc.code, - }, + requested_url=requested_url, + status_code=exc.code, + final_url=final_url, + block_reason=block_reason, ) return FetchResult( requested_url=requested_url, status_code=exc.code, - final_url=exc.geturl(), - body=LiveScholarSource._http_error_body(exc), + final_url=final_url, + body=body, error=str(exc), ) @@ -177,13 +216,12 @@ class LiveScholarSource: def _success_result(requested_url: str, response) -> FetchResult: body = response.read().decode("utf-8", errors="replace") status_code = getattr(response, "status", 200) - logger.debug( + structured_log( + logger, + "debug", "scholar_source.fetch_succeeded", - extra={ - "event": "scholar_source.fetch_succeeded", - "requested_url": requested_url, - "status_code": status_code, - }, + requested_url=requested_url, + status_code=status_code, ) return FetchResult( requested_url=requested_url, diff --git a/app/services/domains/scholar/state_detection.py b/app/services/scholar/state_detection.py similarity index 92% rename from app/services/domains/scholar/state_detection.py rename to app/services/scholar/state_detection.py index cdc8780..28a413d 100644 --- a/app/services/domains/scholar/state_detection.py +++ b/app/services/scholar/state_detection.py @@ -1,6 +1,6 @@ from __future__ import annotations -from app.services.domains.scholar.parser_constants import ( +from app.services.scholar.parser_constants import ( BLOCKED_KEYWORDS, NETWORK_DNS_ERROR_KEYWORDS, NETWORK_TIMEOUT_KEYWORDS, @@ -8,8 +8,8 @@ from app.services.domains.scholar.parser_constants import ( NO_AUTHOR_RESULTS_KEYWORDS, NO_RESULTS_KEYWORDS, ) -from app.services.domains.scholar.parser_types import ParseState, PublicationCandidate, ScholarSearchCandidate -from app.services.domains.scholar.source import FetchResult +from app.services.scholar.parser_types import ParseState, PublicationCandidate, ScholarSearchCandidate +from app.services.scholar.source import FetchResult def classify_network_error_reason(fetch_error: str | None) -> str: @@ -30,6 +30,11 @@ def classify_network_error_reason(fetch_error: str | None) -> str: return "network_error_missing_status_code" +def is_hard_challenge_reason(state_reason: str) -> bool: + """True if the block reason indicates an active Scholar challenge (not retryable in short loops).""" + return state_reason.startswith("blocked_") and state_reason != "blocked_http_429_rate_limited" + + def classify_block_or_captcha_reason( *, status_code: int, @@ -38,18 +43,18 @@ def classify_block_or_captcha_reason( ) -> str | None: if "accounts.google.com" in final_url and ("signin" in final_url or "servicelogin" in final_url): return "blocked_accounts_redirect" - if status_code == 429: - return "blocked_http_429_rate_limited" - if status_code == 403: - if "recaptcha" in body_lowered or "captcha" in body_lowered or "sorry/index" in final_url: - return "blocked_http_403_captcha_challenge" - return "blocked_http_403_forbidden" if "sorry/index" in final_url or "sorry/index" in body_lowered: return "blocked_google_sorry_challenge" if "our systems have detected" in body_lowered or "unusual traffic" in body_lowered: return "blocked_unusual_traffic_detected" if "automated queries" in body_lowered: return "blocked_automated_queries_detected" + if status_code == 429: + return "blocked_http_429_rate_limited" + if status_code == 403: + if "recaptcha" in body_lowered or "captcha" in body_lowered: + return "blocked_http_403_captcha_challenge" + return "blocked_http_403_forbidden" if "not a robot" in body_lowered: return "blocked_not_a_robot_challenge" if "recaptcha" in body_lowered: diff --git a/app/services/domains/scholar/__init__.py b/app/services/scholars/__init__.py similarity index 100% rename from app/services/domains/scholar/__init__.py rename to app/services/scholars/__init__.py diff --git a/app/services/scholars/application.py b/app/services/scholars/application.py new file mode 100644 index 0000000..894c371 --- /dev/null +++ b/app/services/scholars/application.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import os +from uuid import uuid4 + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ScholarProfile +from app.services.scholar.parser import ScholarParserError, parse_profile_page +from app.services.scholar.source import ScholarSource +from app.services.scholars.author_search import search_author_candidates +from app.services.scholars.constants import ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES +from app.services.scholars.exceptions import ScholarServiceError +from app.services.scholars.uploads import ( + _ensure_upload_root, + _resolve_upload_path, + _safe_remove_upload, +) +from app.services.scholars.validators import ( + normalize_display_name, + normalize_profile_image_url, + validate_scholar_id, +) + +__all__ = [ + "ScholarServiceError", + "clear_profile_image_customization", + "create_scholar_for_user", + "delete_scholar", + "get_user_scholar_by_id", + "hydrate_profile_metadata", + "list_scholars_for_user", + "normalize_display_name", + "normalize_profile_image_url", + "search_author_candidates", + "set_profile_image_override_url", + "set_profile_image_upload", + "toggle_scholar_enabled", + "validate_scholar_id", +] + + +async def list_scholars_for_user( + db_session: AsyncSession, + *, + user_id: int, +) -> list[ScholarProfile]: + from sqlalchemy import select + + result = await db_session.execute( + select(ScholarProfile) + .where(ScholarProfile.user_id == user_id) + .order_by(ScholarProfile.created_at.desc(), ScholarProfile.id.desc()) + ) + return list(result.scalars().all()) + + +async def create_scholar_for_user( + db_session: AsyncSession, + *, + user_id: int, + scholar_id: str, + display_name: str, + profile_image_url: str | None = None, +) -> ScholarProfile: + profile = ScholarProfile( + user_id=user_id, + scholar_id=validate_scholar_id(scholar_id), + display_name=normalize_display_name(display_name), + profile_image_url=normalize_profile_image_url(profile_image_url), + ) + db_session.add(profile) + try: + await db_session.commit() + except IntegrityError as exc: + await db_session.rollback() + raise ScholarServiceError("That scholar is already tracked for this account.") from exc + await db_session.refresh(profile) + return profile + + +async def get_user_scholar_by_id( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int, +) -> ScholarProfile | None: + from sqlalchemy import select + + result = await db_session.execute( + select(ScholarProfile).where( + ScholarProfile.id == scholar_profile_id, + ScholarProfile.user_id == user_id, + ) + ) + return result.scalar_one_or_none() + + +async def toggle_scholar_enabled( + db_session: AsyncSession, + *, + profile: ScholarProfile, +) -> ScholarProfile: + profile.is_enabled = not profile.is_enabled + await db_session.commit() + await db_session.refresh(profile) + return profile + + +async def delete_scholar( + db_session: AsyncSession, + *, + profile: ScholarProfile, + upload_dir: str | None = None, +) -> None: + if upload_dir: + upload_root = _ensure_upload_root(upload_dir, create=True) + _safe_remove_upload(upload_root, profile.profile_image_upload_path) + + await db_session.delete(profile) + await db_session.commit() + + +async def hydrate_profile_metadata( + db_session: AsyncSession, + *, + profile: ScholarProfile, + source: ScholarSource, +) -> ScholarProfile: + fetch_result = await source.fetch_profile_html(profile.scholar_id) + try: + parsed_page = parse_profile_page(fetch_result) + except ScholarParserError: + return profile + + if parsed_page.profile_name and not (profile.display_name or "").strip(): + profile.display_name = parsed_page.profile_name + if parsed_page.profile_image_url and not profile.profile_image_url: + profile.profile_image_url = parsed_page.profile_image_url + + await db_session.commit() + await db_session.refresh(profile) + return profile + + +async def set_profile_image_override_url( + db_session: AsyncSession, + *, + profile: ScholarProfile, + image_url: str | None, + upload_dir: str, +) -> ScholarProfile: + upload_root = _ensure_upload_root(upload_dir, create=True) + _safe_remove_upload(upload_root, profile.profile_image_upload_path) + + profile.profile_image_upload_path = None + profile.profile_image_override_url = normalize_profile_image_url(image_url) + + await db_session.commit() + await db_session.refresh(profile) + return profile + + +async def clear_profile_image_customization( + db_session: AsyncSession, + *, + profile: ScholarProfile, + upload_dir: str, +) -> ScholarProfile: + upload_root = _ensure_upload_root(upload_dir, create=True) + _safe_remove_upload(upload_root, profile.profile_image_upload_path) + + profile.profile_image_upload_path = None + profile.profile_image_override_url = None + + await db_session.commit() + await db_session.refresh(profile) + return profile + + +async def set_profile_image_upload( + db_session: AsyncSession, + *, + profile: ScholarProfile, + content_type: str | None, + image_bytes: bytes, + upload_dir: str, + max_upload_bytes: int, +) -> ScholarProfile: + normalized_content_type = (content_type or "").strip().lower() + extension = ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES.get(normalized_content_type) + if extension is None: + raise ScholarServiceError("Unsupported image type. Use JPEG, PNG, WEBP, or GIF.") + + if not image_bytes: + raise ScholarServiceError("Uploaded image file is empty.") + + if len(image_bytes) > max_upload_bytes: + raise ScholarServiceError(f"Uploaded image exceeds {max_upload_bytes} bytes.") + + upload_root = _ensure_upload_root(upload_dir, create=True) + user_dir = upload_root / str(profile.user_id) + user_dir.mkdir(parents=True, exist_ok=True) + + filename = f"{profile.id}_{uuid4().hex}{extension}" + relative_path = os.path.join(str(profile.user_id), filename) + absolute_path = _resolve_upload_path(upload_root, relative_path) + absolute_path.write_bytes(image_bytes) + + old_path = profile.profile_image_upload_path + profile.profile_image_upload_path = relative_path + profile.profile_image_override_url = None + + await db_session.commit() + await db_session.refresh(profile) + + if old_path and old_path != relative_path: + _safe_remove_upload(upload_root, old_path) + + return profile diff --git a/app/services/scholars/author_search.py b/app/services/scholars/author_search.py new file mode 100644 index 0000000..a9a9a02 --- /dev/null +++ b/app/services/scholars/author_search.py @@ -0,0 +1,580 @@ +from __future__ import annotations + +import asyncio +import logging +import random +from dataclasses import replace +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import AuthorSearchRuntimeState +from app.logging_utils import structured_log +from app.services.scholar.parser import ( + ParsedAuthorSearchPage, + ParseState, + ScholarParserError, + parse_author_search_page, +) +from app.services.scholar.source import ScholarSource +from app.services.scholars.author_search_cache import ( + cache_get_author_search_result, + cache_set_author_search_result, +) +from app.services.scholars.constants import ( + AUTHOR_SEARCH_LOCK_KEY, + AUTHOR_SEARCH_LOCK_NAMESPACE, + AUTHOR_SEARCH_RUNTIME_STATE_KEY, + DEFAULT_AUTHOR_SEARCH_BLOCKED_CACHE_TTL_SECONDS, + DEFAULT_AUTHOR_SEARCH_CACHE_MAX_ENTRIES, + DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD, + DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD, + DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS, + DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS, + DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS, + DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD, + MAX_AUTHOR_SEARCH_LIMIT, + SEARCH_CACHED_BLOCK_REASON, + SEARCH_COOLDOWN_REASON, + SEARCH_DISABLED_REASON, +) +from app.services.scholars.exceptions import ScholarServiceError +from app.services.scholars.search_hints import ( + _merge_warnings, + _policy_blocked_author_search_result, + _trim_author_search_result, +) + +logger = logging.getLogger(__name__) + + +async def _acquire_author_search_lock(db_session: AsyncSession) -> None: + await db_session.execute( + text("SELECT pg_advisory_xact_lock(:namespace, :lock_key)"), + { + "namespace": AUTHOR_SEARCH_LOCK_NAMESPACE, + "lock_key": AUTHOR_SEARCH_LOCK_KEY, + }, + ) + + +async def _load_runtime_state_for_update( + db_session: AsyncSession, +) -> AuthorSearchRuntimeState: + result = await db_session.execute( + select(AuthorSearchRuntimeState) + .where(AuthorSearchRuntimeState.state_key == AUTHOR_SEARCH_RUNTIME_STATE_KEY) + .with_for_update() + ) + state = result.scalar_one_or_none() + if state is not None: + return state + + state = AuthorSearchRuntimeState(state_key=AUTHOR_SEARCH_RUNTIME_STATE_KEY) + db_session.add(state) + await db_session.flush() + return state + + +def _is_author_search_block_state(parsed: ParsedAuthorSearchPage) -> bool: + return parsed.state == ParseState.BLOCKED_OR_CAPTCHA + + +def _author_search_cooldown_remaining_seconds( + runtime_state: AuthorSearchRuntimeState, + now_utc: datetime, +) -> int: + cooldown_until = runtime_state.cooldown_until + if cooldown_until is None: + return 0 + if cooldown_until.tzinfo is None: + cooldown_until = cooldown_until.replace(tzinfo=UTC) + remaining_seconds = int((cooldown_until - now_utc).total_seconds()) + return max(0, remaining_seconds) + + +def _normalize_author_search_inputs(query: str, limit: int) -> tuple[str, int, str]: + normalized_query = query.strip() + if len(normalized_query) < 2: + raise ScholarServiceError("Search query must be at least 2 characters.") + bounded_limit = max(1, min(int(limit), MAX_AUTHOR_SEARCH_LIMIT)) + return normalized_query, bounded_limit, normalized_query.casefold() + + +def _disabled_search_result(*, normalized_query: str, bounded_limit: int) -> ParsedAuthorSearchPage: + structured_log( + logger, + "warning", + "scholar_search.disabled_by_configuration", + query=normalized_query, + ) + return _policy_blocked_author_search_result( + reason=SEARCH_DISABLED_REASON, + warning_codes=["author_search_disabled_by_configuration"], + limit=bounded_limit, + ) + + +def _normalize_runtime_cooldown_state( + runtime_state: AuthorSearchRuntimeState, + *, + now_utc: datetime, +) -> bool: + if runtime_state.cooldown_until is None: + return False + cooldown_until = runtime_state.cooldown_until + updated = False + if cooldown_until.tzinfo is None: + cooldown_until = cooldown_until.replace(tzinfo=UTC) + runtime_state.cooldown_until = cooldown_until + updated = True + if now_utc < cooldown_until: + return updated + structured_log( + logger, + "info", + "scholar_search.cooldown_expired", + cooldown_until_utc=cooldown_until.isoformat(), + ) + runtime_state.cooldown_until = None + runtime_state.cooldown_rejection_count = 0 + runtime_state.cooldown_alert_emitted = False + return True + + +def _cooldown_warning_codes( + *, + runtime_state: AuthorSearchRuntimeState, + cooldown_remaining_seconds: int, +) -> list[str]: + warning_codes = [ + "author_search_cooldown_active", + f"author_search_cooldown_remaining_{cooldown_remaining_seconds}s", + ] + if bool(runtime_state.cooldown_alert_emitted): + warning_codes.append("author_search_cooldown_alert_threshold_exceeded") + return warning_codes + + +def _emit_cooldown_threshold_alert( + *, + runtime_state: AuthorSearchRuntimeState, + normalized_query: str, + cooldown_rejection_alert_threshold: int, +) -> bool: + runtime_state.cooldown_rejection_count = int(runtime_state.cooldown_rejection_count) + 1 + threshold = max(1, int(cooldown_rejection_alert_threshold)) + if int(runtime_state.cooldown_rejection_count) < threshold: + return True + if bool(runtime_state.cooldown_alert_emitted): + return True + structured_log( + logger, + "error", + "scholar_search.cooldown_rejection_threshold_exceeded", + query=normalized_query, + cooldown_rejection_count=int(runtime_state.cooldown_rejection_count), + threshold=threshold, + cooldown_until_utc=runtime_state.cooldown_until.isoformat() if runtime_state.cooldown_until else None, + ) + runtime_state.cooldown_alert_emitted = True + return True + + +def _cooldown_block_result( + *, + runtime_state: AuthorSearchRuntimeState, + normalized_query: str, + bounded_limit: int, + cooldown_rejection_alert_threshold: int, + cooldown_remaining_seconds: int, +) -> ParsedAuthorSearchPage: + _emit_cooldown_threshold_alert( + runtime_state=runtime_state, + normalized_query=normalized_query, + cooldown_rejection_alert_threshold=cooldown_rejection_alert_threshold, + ) + structured_log( + logger, + "warning", + "scholar_search.cooldown_active", + query=normalized_query, + cooldown_remaining_seconds=cooldown_remaining_seconds, + cooldown_until_utc=runtime_state.cooldown_until.isoformat() if runtime_state.cooldown_until else None, + ) + return _policy_blocked_author_search_result( + reason=SEARCH_COOLDOWN_REASON, + warning_codes=_cooldown_warning_codes( + runtime_state=runtime_state, + cooldown_remaining_seconds=cooldown_remaining_seconds, + ), + limit=bounded_limit, + ) + + +async def _cache_hit_result( + db_session: AsyncSession, + *, + query_key: str, + now_utc: datetime, + normalized_query: str, + bounded_limit: int, +) -> ParsedAuthorSearchPage | None: + cached = await cache_get_author_search_result( + db_session, + query_key=query_key, + now_utc=now_utc, + ) + if cached is None: + return None + structured_log( + logger, + "info", + "scholar_search.cache_hit", + query=normalized_query, + state=cached.state.value, + state_reason=cached.state_reason, + ) + state_reason_override = SEARCH_CACHED_BLOCK_REASON if _is_author_search_block_state(cached) else None + return _trim_author_search_result( + cached, + limit=bounded_limit, + extra_warnings=["author_search_served_from_cache"], + state_reason_override=state_reason_override, + ) + + +def _throttle_sleep_seconds( + *, + runtime_state: AuthorSearchRuntimeState, + now_utc: datetime, + min_interval_seconds: float, + interval_jitter_seconds: float, +) -> tuple[float, bool]: + updated = False + if runtime_state.last_live_request_at is None: + enforced_wait_seconds = 0.0 + else: + last_live_request_at = runtime_state.last_live_request_at + if last_live_request_at.tzinfo is None: + last_live_request_at = last_live_request_at.replace(tzinfo=UTC) + runtime_state.last_live_request_at = last_live_request_at + updated = True + enforced_wait_seconds = ( + last_live_request_at + timedelta(seconds=max(float(min_interval_seconds), 0.0)) - now_utc + ).total_seconds() + jitter_seconds = random.uniform(0.0, max(float(interval_jitter_seconds), 0.0)) + return max(0.0, float(enforced_wait_seconds)) + jitter_seconds, updated + + +async def _wait_for_author_search_throttle( + *, + runtime_state: AuthorSearchRuntimeState, + normalized_query: str, + now_utc: datetime, + min_interval_seconds: float, + interval_jitter_seconds: float, +) -> bool: + sleep_seconds, updated = _throttle_sleep_seconds( + runtime_state=runtime_state, + now_utc=now_utc, + min_interval_seconds=min_interval_seconds, + interval_jitter_seconds=interval_jitter_seconds, + ) + if sleep_seconds <= 0.0: + return updated + structured_log( + logger, + "info", + "scholar_search.throttle_wait", + query=normalized_query, + sleep_seconds=round(sleep_seconds, 3), + ) + await asyncio.sleep(sleep_seconds) + return True + + +async def _fetch_author_search_with_retries( + *, + source: ScholarSource, + normalized_query: str, + network_error_retries: int, + retry_backoff_seconds: float, +) -> tuple[ParsedAuthorSearchPage, int, list[str]]: + max_attempts = max(1, int(network_error_retries) + 1) + parsed: ParsedAuthorSearchPage | None = None + retry_warnings: list[str] = [] + retry_scheduled_count = 0 + for attempt_index in range(max_attempts): + fetch_result = await source.fetch_author_search_html(normalized_query, start=0) + try: + parsed = parse_author_search_page(fetch_result) + except ScholarParserError as exc: + parsed = ParsedAuthorSearchPage( + state=ParseState.LAYOUT_CHANGED, + state_reason=exc.code, + candidates=[], + marker_counts={}, + warnings=[exc.code], + ) + if parsed.state != ParseState.NETWORK_ERROR or attempt_index >= max_attempts - 1: + break + retry_warnings.append("network_retry_scheduled_for_author_search") + retry_scheduled_count += 1 + retry_sleep_seconds = max(float(retry_backoff_seconds), 0.0) * (2**attempt_index) + if retry_sleep_seconds > 0: + await asyncio.sleep(retry_sleep_seconds) + if parsed is None: + raise ScholarServiceError("Unable to complete scholar author search.") + return parsed, retry_scheduled_count, retry_warnings + + +def _with_retry_warnings( + parsed: ParsedAuthorSearchPage, + *, + retry_warnings: list[str], + retry_scheduled_count: int, + retry_alert_threshold: int, + normalized_query: str, +) -> ParsedAuthorSearchPage: + merged = replace(parsed, warnings=_merge_warnings(parsed.warnings, retry_warnings)) + threshold = max(1, int(retry_alert_threshold)) + if retry_scheduled_count < threshold: + return merged + structured_log( + logger, + "warning", + "scholar_search.retry_threshold_exceeded", + query=normalized_query, + retry_scheduled_count=retry_scheduled_count, + threshold=threshold, + final_state=merged.state.value, + final_state_reason=merged.state_reason, + ) + return replace( + merged, + warnings=_merge_warnings( + merged.warnings, + [f"author_search_retry_threshold_exceeded_{retry_scheduled_count}"], + ), + ) + + +def _apply_block_circuit_breaker( + *, + runtime_state: AuthorSearchRuntimeState, + merged_parsed: ParsedAuthorSearchPage, + cooldown_block_threshold: int, + cooldown_seconds: int, + normalized_query: str, +) -> ParsedAuthorSearchPage: + if not _is_author_search_block_state(merged_parsed): + runtime_state.consecutive_blocked_count = 0 + return merged_parsed + runtime_state.consecutive_blocked_count = int(runtime_state.consecutive_blocked_count) + 1 + structured_log( + logger, + "warning", + "scholar_search.block_detected", + query=normalized_query, + state_reason=merged_parsed.state_reason, + consecutive_blocked_count=int(runtime_state.consecutive_blocked_count), + ) + if int(runtime_state.consecutive_blocked_count) < max(1, int(cooldown_block_threshold)): + return merged_parsed + runtime_state.cooldown_until = datetime.now(UTC) + timedelta(seconds=max(60, int(cooldown_seconds))) + runtime_state.consecutive_blocked_count = 0 + runtime_state.cooldown_rejection_count = 0 + runtime_state.cooldown_alert_emitted = False + structured_log( + logger, + "error", + "scholar_search.cooldown_activated", + query=normalized_query, + cooldown_until_utc=runtime_state.cooldown_until.isoformat() if runtime_state.cooldown_until else None, + ) + return replace( + merged_parsed, + warnings=_merge_warnings(merged_parsed.warnings, ["author_search_circuit_breaker_armed"]), + ) + + +def _resolve_author_search_cache_ttl_seconds( + *, + merged_parsed: ParsedAuthorSearchPage, + blocked_cache_ttl_seconds: int, + cache_ttl_seconds: int, +) -> int: + if _is_author_search_block_state(merged_parsed): + return min(max(1, int(blocked_cache_ttl_seconds)), max(1, int(cache_ttl_seconds))) + return max(1, int(cache_ttl_seconds)) + + +async def _load_locked_runtime_state( + db_session: AsyncSession, +) -> AuthorSearchRuntimeState: + await _acquire_author_search_lock(db_session) + return await _load_runtime_state_for_update(db_session) + + +async def _cooldown_or_cache_result( + db_session: AsyncSession, + *, + runtime_state: AuthorSearchRuntimeState, + query_key: str, + normalized_query: str, + bounded_limit: int, + cooldown_rejection_alert_threshold: int, +) -> tuple[ParsedAuthorSearchPage | None, bool]: + runtime_state_updated = _normalize_runtime_cooldown_state( + runtime_state, + now_utc=datetime.now(UTC), + ) + cooldown_remaining_seconds = _author_search_cooldown_remaining_seconds( + runtime_state, + datetime.now(UTC), + ) + if cooldown_remaining_seconds > 0: + return ( + _cooldown_block_result( + runtime_state=runtime_state, + normalized_query=normalized_query, + bounded_limit=bounded_limit, + cooldown_rejection_alert_threshold=cooldown_rejection_alert_threshold, + cooldown_remaining_seconds=cooldown_remaining_seconds, + ), + True, + ) + cached_result = await _cache_hit_result( + db_session, + query_key=query_key, + now_utc=datetime.now(UTC), + normalized_query=normalized_query, + bounded_limit=bounded_limit, + ) + return cached_result, runtime_state_updated + + +async def _perform_live_author_search( + db_session: AsyncSession, + *, + source: ScholarSource, + runtime_state: AuthorSearchRuntimeState, + normalized_query: str, + query_key: str, + network_error_retries: int, + retry_backoff_seconds: float, + min_interval_seconds: float, + interval_jitter_seconds: float, + retry_alert_threshold: int, + cooldown_block_threshold: int, + cooldown_seconds: int, + blocked_cache_ttl_seconds: int, + cache_ttl_seconds: int, + cache_max_entries: int, +) -> tuple[ParsedAuthorSearchPage, bool]: + await _wait_for_author_search_throttle( + runtime_state=runtime_state, + normalized_query=normalized_query, + now_utc=datetime.now(UTC), + min_interval_seconds=min_interval_seconds, + interval_jitter_seconds=interval_jitter_seconds, + ) + parsed, retry_count, retry_warnings = await _fetch_author_search_with_retries( + source=source, + normalized_query=normalized_query, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + ) + runtime_state.last_live_request_at = datetime.now(UTC) + merged = _with_retry_warnings( + parsed, + retry_warnings=retry_warnings, + retry_scheduled_count=retry_count, + retry_alert_threshold=retry_alert_threshold, + normalized_query=normalized_query, + ) + merged = _apply_block_circuit_breaker( + runtime_state=runtime_state, + merged_parsed=merged, + cooldown_block_threshold=cooldown_block_threshold, + cooldown_seconds=cooldown_seconds, + normalized_query=normalized_query, + ) + ttl_seconds = _resolve_author_search_cache_ttl_seconds( + merged_parsed=merged, + blocked_cache_ttl_seconds=blocked_cache_ttl_seconds, + cache_ttl_seconds=cache_ttl_seconds, + ) + await cache_set_author_search_result( + db_session, + query_key=query_key, + parsed=merged, + ttl_seconds=float(ttl_seconds), + max_entries=cache_max_entries, + now_utc=datetime.now(UTC), + ) + return merged, True + + +async def search_author_candidates( + *, + source: ScholarSource, + db_session: AsyncSession, + query: str, + limit: int, + network_error_retries: int = 1, + retry_backoff_seconds: float = 1.0, + search_enabled: bool = True, + cache_ttl_seconds: int = 21_600, + blocked_cache_ttl_seconds: int = DEFAULT_AUTHOR_SEARCH_BLOCKED_CACHE_TTL_SECONDS, + cache_max_entries: int = DEFAULT_AUTHOR_SEARCH_CACHE_MAX_ENTRIES, + min_interval_seconds: float = DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS, + interval_jitter_seconds: float = DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS, + cooldown_block_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD, + cooldown_seconds: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS, + retry_alert_threshold: int = DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD, + cooldown_rejection_alert_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD, +) -> ParsedAuthorSearchPage: + normalized_query, bounded_limit, query_key = _normalize_author_search_inputs(query, limit) + if not search_enabled: + return _disabled_search_result( + normalized_query=normalized_query, + bounded_limit=bounded_limit, + ) + + runtime_state = await _load_locked_runtime_state(db_session) + early_result, runtime_state_updated = await _cooldown_or_cache_result( + db_session, + runtime_state=runtime_state, + query_key=query_key, + normalized_query=normalized_query, + bounded_limit=bounded_limit, + cooldown_rejection_alert_threshold=cooldown_rejection_alert_threshold, + ) + if early_result is not None: + await db_session.commit() + return early_result + + merged_parsed, live_runtime_updated = await _perform_live_author_search( + db_session, + source=source, + runtime_state=runtime_state, + normalized_query=normalized_query, + query_key=query_key, + network_error_retries=network_error_retries, + retry_backoff_seconds=retry_backoff_seconds, + min_interval_seconds=min_interval_seconds, + interval_jitter_seconds=interval_jitter_seconds, + retry_alert_threshold=retry_alert_threshold, + cooldown_block_threshold=cooldown_block_threshold, + cooldown_seconds=cooldown_seconds, + blocked_cache_ttl_seconds=blocked_cache_ttl_seconds, + cache_ttl_seconds=cache_ttl_seconds, + cache_max_entries=cache_max_entries, + ) + runtime_state_updated = runtime_state_updated or live_runtime_updated + if runtime_state_updated: + await db_session.commit() + return _trim_author_search_result(merged_parsed, limit=bounded_limit) diff --git a/app/services/scholars/author_search_cache.py b/app/services/scholars/author_search_cache.py new file mode 100644 index 0000000..a0bab2f --- /dev/null +++ b/app/services/scholars/author_search_cache.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import cast + +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import AuthorSearchCacheEntry +from app.services.scholar.parser import ( + ParsedAuthorSearchPage, + ParseState, + ScholarSearchCandidate, +) + + +def _serialize_parsed_author_search_page(parsed: ParsedAuthorSearchPage) -> dict: + return { + "state": parsed.state.value, + "state_reason": parsed.state_reason, + "marker_counts": {str(key): int(value) for key, value in parsed.marker_counts.items()}, + "warnings": [str(value) for value in parsed.warnings], + "candidates": [ + { + "scholar_id": candidate.scholar_id, + "display_name": candidate.display_name, + "affiliation": candidate.affiliation, + "email_domain": candidate.email_domain, + "cited_by_count": candidate.cited_by_count, + "interests": [str(interest) for interest in candidate.interests], + "profile_url": candidate.profile_url, + "profile_image_url": candidate.profile_image_url, + } + for candidate in parsed.candidates + ], + } + + +def _payload_state(payload: dict[str, object]) -> ParseState | None: + state_raw = str(payload.get("state", "")).strip() + try: + return ParseState(state_raw) + except ValueError: + return None + + +def _payload_marker_counts(payload: dict[str, object]) -> dict[str, int]: + marker_counts_payload = payload.get("marker_counts") + if not isinstance(marker_counts_payload, dict): + return {} + parsed: dict[str, int] = {} + for key, value in marker_counts_payload.items(): + try: + parsed[str(key)] = int(value) + except (TypeError, ValueError): + continue + return parsed + + +def _payload_warnings(payload: dict[str, object]) -> list[str]: + warnings_payload = payload.get("warnings") + if not isinstance(warnings_payload, list): + return [] + return [str(value) for value in warnings_payload if isinstance(value, str)] + + +def _parse_optional_string(value: object) -> str | None: + if value is None: + return None + normalized = str(value).strip() + return normalized or None + + +def _parse_optional_int(value: object) -> int | None: + if isinstance(value, int): + return value + if isinstance(value, str) and value.strip(): + try: + return int(value) + except ValueError: + return None + return None + + +def _normalize_interests(value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if isinstance(item, str)] + + +def _deserialize_candidate_payload(value: object) -> dict[str, object] | None: + if not isinstance(value, dict): + return None + scholar_id = str(value.get("scholar_id", "")).strip() + display_name = str(value.get("display_name", "")).strip() + profile_url = str(value.get("profile_url", "")).strip() + if not scholar_id or not display_name or not profile_url: + return None + return { + "scholar_id": scholar_id, + "display_name": display_name, + "affiliation": _parse_optional_string(value.get("affiliation")), + "email_domain": _parse_optional_string(value.get("email_domain")), + "cited_by_count": _parse_optional_int(value.get("cited_by_count")), + "interests": _normalize_interests(value.get("interests")), + "profile_url": profile_url, + "profile_image_url": _parse_optional_string(value.get("profile_image_url")), + } + + +def _deserialize_candidates(payload: dict[str, object]) -> list[dict[str, object]]: + candidates_payload = payload.get("candidates") + if not isinstance(candidates_payload, list): + return [] + normalized: list[dict[str, object]] = [] + for value in candidates_payload: + candidate = _deserialize_candidate_payload(value) + if candidate is not None: + normalized.append(candidate) + return normalized + + +def _deserialize_parsed_author_search_page(payload: object) -> ParsedAuthorSearchPage | None: + if not isinstance(payload, dict): + return None + + state = _payload_state(payload) + if state is None: + return None + + marker_counts = _payload_marker_counts(payload) + warnings = _payload_warnings(payload) + normalized_candidates = _deserialize_candidates(payload) + + return ParsedAuthorSearchPage( + state=state, + state_reason=str(payload.get("state_reason", "")).strip() or "unknown", + candidates=[ + ScholarSearchCandidate( + scholar_id=cast(str, item["scholar_id"]), + display_name=cast(str, item["display_name"]), + affiliation=cast("str | None", item["affiliation"]), + email_domain=cast("str | None", item["email_domain"]), + cited_by_count=cast("int | None", item["cited_by_count"]), + interests=cast("list[str]", item["interests"]), + profile_url=cast(str, item["profile_url"]), + profile_image_url=cast("str | None", item["profile_image_url"]), + ) + for item in normalized_candidates + ], + marker_counts=marker_counts, + warnings=warnings, + ) + + +async def cache_get_author_search_result( + db_session: AsyncSession, + *, + query_key: str, + now_utc: datetime, +) -> ParsedAuthorSearchPage | None: + result = await db_session.execute( + select(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.query_key == query_key) + ) + entry = result.scalar_one_or_none() + if entry is None: + return None + expires_at = entry.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=UTC) + if expires_at <= now_utc: + await db_session.delete(entry) + return None + parsed = _deserialize_parsed_author_search_page(entry.payload) + if parsed is None: + await db_session.delete(entry) + return None + return parsed + + +async def cache_set_author_search_result( + db_session: AsyncSession, + *, + query_key: str, + parsed: ParsedAuthorSearchPage, + ttl_seconds: float, + max_entries: int, + now_utc: datetime, +) -> None: + ttl = max(float(ttl_seconds), 0.0) + existing_result = await db_session.execute( + select(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.query_key == query_key) + ) + existing = existing_result.scalar_one_or_none() + + if ttl <= 0.0: + if existing is not None: + await db_session.delete(existing) + return + + expires_at = now_utc + timedelta(seconds=ttl) + payload = _serialize_parsed_author_search_page(parsed) + if existing is None: + db_session.add( + AuthorSearchCacheEntry( + query_key=query_key, + payload=payload, + expires_at=expires_at, + cached_at=now_utc, + updated_at=now_utc, + ) + ) + else: + existing.payload = payload + existing.expires_at = expires_at + existing.cached_at = now_utc + existing.updated_at = now_utc + + await prune_author_search_cache(db_session, now_utc=now_utc, max_entries=max_entries) + + +async def prune_author_search_cache( + db_session: AsyncSession, + *, + now_utc: datetime, + max_entries: int, +) -> None: + await db_session.execute(delete(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.expires_at <= now_utc)) + bounded_max_entries = max(1, int(max_entries)) + count_result = await db_session.execute(select(func.count()).select_from(AuthorSearchCacheEntry)) + entry_count = int(count_result.scalar_one() or 0) + overflow = max(0, entry_count - bounded_max_entries) + if overflow <= 0: + return + stale_keys_result = await db_session.execute( + select(AuthorSearchCacheEntry.query_key).order_by(AuthorSearchCacheEntry.cached_at.asc()).limit(overflow) + ) + stale_keys = [str(row[0]) for row in stale_keys_result.all()] + if stale_keys: + await db_session.execute(delete(AuthorSearchCacheEntry).where(AuthorSearchCacheEntry.query_key.in_(stale_keys))) diff --git a/app/services/domains/scholars/constants.py b/app/services/scholars/constants.py similarity index 67% rename from app/services/domains/scholars/constants.py rename to app/services/scholars/constants.py index 7ee18ad..0602acd 100644 --- a/app/services/domains/scholars/constants.py +++ b/app/services/scholars/constants.py @@ -32,39 +32,25 @@ SEARCH_CACHED_BLOCK_REASON = "search_temporarily_disabled_from_cached_blocked_re STATE_REASON_HINTS: dict[str, str] = { SEARCH_DISABLED_REASON: ( - "Scholar name search is currently disabled by service policy. " - "Add scholars by profile URL or Scholar ID." + "Scholar name search is currently disabled by service policy. Add scholars by profile URL or Scholar ID." ), SEARCH_COOLDOWN_REASON: ( "Scholar name search is temporarily paused after repeated block responses. " "Use Scholar URL/ID adds until cooldown expires." ), SEARCH_CACHED_BLOCK_REASON: ( - "A recent blocked response was cached to reduce traffic. " - "Retry later or add by Scholar URL/ID." + "A recent blocked response was cached to reduce traffic. Retry later or add by Scholar URL/ID." ), "network_dns_resolution_failed": ( - "DNS resolution failed while reaching scholar.google.com. " - "Verify container DNS/network and retry." - ), - "network_timeout": ( - "Request timed out before Google Scholar responded. " - "Increase delay/backoff and retry." - ), - "network_tls_error": ( - "TLS handshake/certificate validation failed. " - "Verify outbound TLS/network configuration." - ), - "blocked_http_429_rate_limited": ( - "Google Scholar rate-limited the request. " - "Slow request cadence and retry later." + "DNS resolution failed while reaching scholar.google.com. Verify container DNS/network and retry." ), + "network_timeout": ("Request timed out before Google Scholar responded. Increase delay/backoff and retry."), + "network_tls_error": ("TLS handshake/certificate validation failed. Verify outbound TLS/network configuration."), + "blocked_http_429_rate_limited": ("Google Scholar rate-limited the request. Slow request cadence and retry later."), "blocked_unusual_traffic_detected": ( - "Google Scholar flagged traffic as unusual. " - "Increase delay/jitter and reduce concurrent scraping." + "Google Scholar flagged traffic as unusual. Increase delay/jitter and reduce concurrent scraping." ), "blocked_accounts_redirect": ( - "Request was redirected to Google Account sign-in. " - "Treat as access block and retry later." + "Request was redirected to Google Account sign-in. Treat as access block and retry later." ), } diff --git a/app/services/domains/scholars/exceptions.py b/app/services/scholars/exceptions.py similarity index 100% rename from app/services/domains/scholars/exceptions.py rename to app/services/scholars/exceptions.py diff --git a/app/services/domains/scholars/search_hints.py b/app/services/scholars/search_hints.py similarity index 93% rename from app/services/domains/scholars/search_hints.py rename to app/services/scholars/search_hints.py index f905c14..7c5cf15 100644 --- a/app/services/domains/scholars/search_hints.py +++ b/app/services/scholars/search_hints.py @@ -1,8 +1,8 @@ from __future__ import annotations from app.db.models import ScholarProfile -from app.services.domains.scholar.parser import ParseState, ParsedAuthorSearchPage -from app.services.domains.scholars.constants import ( +from app.services.scholar.parser import ParsedAuthorSearchPage, ParseState +from app.services.scholars.constants import ( MAX_AUTHOR_SEARCH_LIMIT, STATE_REASON_HINTS, ) diff --git a/app/services/domains/scholars/uploads.py b/app/services/scholars/uploads.py similarity index 93% rename from app/services/domains/scholars/uploads.py rename to app/services/scholars/uploads.py index c33fd47..b0cf766 100644 --- a/app/services/domains/scholars/uploads.py +++ b/app/services/scholars/uploads.py @@ -2,7 +2,7 @@ from __future__ import annotations from pathlib import Path -from app.services.domains.scholars.exceptions import ScholarServiceError +from app.services.scholars.exceptions import ScholarServiceError def _ensure_upload_root(upload_dir: str, *, create: bool) -> Path: diff --git a/app/services/domains/scholars/validators.py b/app/services/scholars/validators.py similarity index 75% rename from app/services/domains/scholars/validators.py rename to app/services/scholars/validators.py index 1c79baa..38ee42c 100644 --- a/app/services/domains/scholars/validators.py +++ b/app/services/scholars/validators.py @@ -2,8 +2,8 @@ from __future__ import annotations from urllib.parse import urlparse -from app.services.domains.scholars.constants import MAX_IMAGE_URL_LENGTH, SCHOLAR_ID_PATTERN -from app.services.domains.scholars.exceptions import ScholarServiceError +from app.services.scholars.constants import MAX_IMAGE_URL_LENGTH, SCHOLAR_ID_PATTERN +from app.services.scholars.exceptions import ScholarServiceError def validate_scholar_id(value: str) -> str: @@ -27,9 +27,7 @@ def normalize_profile_image_url(value: str | None) -> str | None: return None if len(candidate) > MAX_IMAGE_URL_LENGTH: - raise ScholarServiceError( - f"Image URL must be {MAX_IMAGE_URL_LENGTH} characters or fewer." - ) + raise ScholarServiceError(f"Image URL must be {MAX_IMAGE_URL_LENGTH} characters or fewer.") parsed = urlparse(candidate) if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: diff --git a/app/services/domains/scholars/__init__.py b/app/services/settings/__init__.py similarity index 100% rename from app/services/domains/scholars/__init__.py rename to app/services/settings/__init__.py diff --git a/app/services/domains/settings/application.py b/app/services/settings/application.py similarity index 80% rename from app/services/domains/settings/application.py rename to app/services/settings/application.py index 3548e44..e35971c 100644 --- a/app/services/domains/settings/application.py +++ b/app/services/settings/application.py @@ -4,6 +4,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import UserSetting +from app.settings import settings as app_settings class UserSettingsServiceError(ValueError): @@ -60,9 +61,7 @@ def parse_run_interval_minutes(value: str, *, minimum: int = HARD_MIN_RUN_INTERV raise UserSettingsServiceError("Check interval must be a whole number.") from exc effective_minimum = resolve_run_interval_minimum(minimum) if parsed < effective_minimum: - raise UserSettingsServiceError( - f"Check interval must be at least {effective_minimum} minutes." - ) + raise UserSettingsServiceError(f"Check interval must be at least {effective_minimum} minutes.") return parsed @@ -73,9 +72,7 @@ def parse_request_delay_seconds(value: str, *, minimum: int = HARD_MIN_REQUEST_D raise UserSettingsServiceError("Request delay must be a whole number.") from exc effective_minimum = resolve_request_delay_minimum(minimum) if parsed < effective_minimum: - raise UserSettingsServiceError( - f"Request delay must be at least {effective_minimum} seconds." - ) + raise UserSettingsServiceError(f"Request delay must be at least {effective_minimum} seconds.") return parsed @@ -102,9 +99,7 @@ def parse_nav_visible_pages(value: object) -> list[str]: missing_required = [page for page in REQUIRED_NAV_PAGES if page not in seen] if missing_required: - raise UserSettingsServiceError( - "Dashboard, Scholars, and Settings must remain visible." - ) + raise UserSettingsServiceError("Dashboard, Scholars, and Settings must remain visible.") return deduped @@ -114,14 +109,17 @@ async def get_or_create_settings( *, user_id: int, ) -> UserSetting: - result = await db_session.execute( - select(UserSetting).where(UserSetting.user_id == user_id) - ) + result = await db_session.execute(select(UserSetting).where(UserSetting.user_id == user_id)) settings = result.scalar_one_or_none() if settings is not None: return settings - settings = UserSetting(user_id=user_id) + settings = UserSetting( + user_id=user_id, + openalex_api_key=app_settings.openalex_api_key, + crossref_api_token=app_settings.crossref_api_token, + crossref_api_mailto=app_settings.crossref_api_mailto, + ) db_session.add(settings) await db_session.commit() await db_session.refresh(settings) @@ -136,11 +134,17 @@ async def update_settings( run_interval_minutes: int, request_delay_seconds: int, nav_visible_pages: list[str], + openalex_api_key: str | None, + crossref_api_token: str | None, + crossref_api_mailto: str | None, ) -> UserSetting: settings.auto_run_enabled = auto_run_enabled settings.run_interval_minutes = run_interval_minutes settings.request_delay_seconds = request_delay_seconds settings.nav_visible_pages = nav_visible_pages + settings.openalex_api_key = openalex_api_key + settings.crossref_api_token = crossref_api_token + settings.crossref_api_mailto = crossref_api_mailto await db_session.commit() await db_session.refresh(settings) return settings diff --git a/app/services/domains/unpaywall/__init__.py b/app/services/unpaywall/__init__.py similarity index 64% rename from app/services/domains/unpaywall/__init__.py rename to app/services/unpaywall/__init__.py index a1baa12..ecfec29 100644 --- a/app/services/domains/unpaywall/__init__.py +++ b/app/services/unpaywall/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations async def resolve_publication_pdf_urls(*args, **kwargs): - from app.services.domains.unpaywall.application import resolve_publication_pdf_urls as _impl + from app.services.unpaywall.application import resolve_publication_pdf_urls as _impl return await _impl(*args, **kwargs) diff --git a/app/services/domains/unpaywall/application.py b/app/services/unpaywall/application.py similarity index 82% rename from app/services/domains/unpaywall/application.py rename to app/services/unpaywall/application.py index 44c7560..f7d23f8 100644 --- a/app/services/domains/unpaywall/application.py +++ b/app/services/unpaywall/application.py @@ -1,22 +1,23 @@ from __future__ import annotations -from dataclasses import dataclass import logging import re +from dataclasses import dataclass from typing import TYPE_CHECKING from urllib.parse import unquote -from app.services.domains.crossref.application import discover_doi_for_publication -from app.services.domains.doi.normalize import normalize_doi -from app.services.domains.unpaywall.pdf_discovery import ( +from app.logging_utils import structured_log +from app.services.crossref.application import discover_doi_for_publication +from app.services.doi.normalize import normalize_doi +from app.services.unpaywall.pdf_discovery import ( looks_like_pdf_url, resolve_pdf_from_landing_page, ) -from app.services.domains.unpaywall.rate_limit import wait_for_unpaywall_slot +from app.services.unpaywall.rate_limit import wait_for_unpaywall_slot from app.settings import settings if TYPE_CHECKING: - from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem + from app.services.publications.types import PublicationListItem, UnreadPublicationItem DOI_PATTERN = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.I) DOI_PREFIX_RE = re.compile(r"\bdoi\s*[:=]\s*(10\.\d{4,9}/[-._;()/:A-Z0-9]+)", re.I) @@ -63,21 +64,18 @@ def _extract_explicit_doi(text: str | None) -> str | None: def _publication_doi(item: PublicationListItem | UnreadPublicationItem) -> str | None: - stored = normalize_doi(item.doi) - if stored: - in_metadata = any( - normalize_doi(_extract_explicit_doi(value)) == stored - for value in (item.pub_url, item.venue_text) - ) - if in_metadata: - return stored + stored = None + di = getattr(item, "display_identifier", None) + if di is not None and di.kind == "doi": + stored = normalize_doi(di.value) + + explicit_doi = _extract_explicit_doi(item.pub_url) or _extract_explicit_doi(item.venue_text) + if explicit_doi: + return explicit_doi pub_url_doi = _extract_doi_candidate(item.pub_url) if pub_url_doi: return normalize_doi(pub_url_doi) - return ( - _extract_explicit_doi(item.pub_url) - or _extract_explicit_doi(item.venue_text) - ) + return stored def _payload_locations(payload: dict) -> list[dict]: @@ -158,10 +156,7 @@ async def _resolved_pdf_url_from_payload( for page_url in _crawl_targets(payload=payload, pdf_candidates=pdf_candidates)[:3]: discovered = await resolve_pdf_from_landing_page(client, page_url=page_url) if discovered: - logger.info( - "unpaywall.pdf_discovered_from_landing", - extra={"event": "unpaywall.pdf_discovered_from_landing", "landing_url": page_url}, - ) + structured_log(logger, "info", "unpaywall.pdf_discovered_from_landing", landing_url=page_url) return discovered return None @@ -173,9 +168,11 @@ async def _fetch_unpaywall_payload_by_doi( email: str, ) -> dict | None: await wait_for_unpaywall_slot(min_interval_seconds=settings.unpaywall_min_interval_seconds) + headers = {"User-Agent": f"scholar-scraper/1.0 (mailto:{email})"} response = await client.get( UNPAYWALL_URL_TEMPLATE.format(doi=doi), params={"email": email}, + headers=headers, ) if response.status_code != 200: return None @@ -190,57 +187,36 @@ def _email_for_request(request_email: str | None) -> str | None: return email or None -def _log_resolution_summary( - *, - publication_count: int, - doi_input_count: int, - search_attempt_count: int, - resolved_pdf_count: int, - email: str, -) -> None: - logger.info( - "unpaywall.resolve_completed", - extra={ - "event": "unpaywall.resolve_completed", - "publication_count": publication_count, - "doi_input_count": doi_input_count, - "search_attempt_count": search_attempt_count, - "resolved_pdf_count": resolved_pdf_count, - "email_domain": email.split("@", 1)[-1] if "@" in email else None, - }, - ) - - async def _resolve_item_payload( *, client, item: PublicationListItem, email: str, allow_crossref: bool, -) -> tuple[dict | None, bool]: +) -> tuple[dict | None, bool, str | None]: doi = _publication_doi(item) payload: dict | None = None if doi: payload = await _fetch_unpaywall_payload_by_doi(client=client, doi=doi, email=email) if payload is not None and _has_direct_payload_pdf(payload): - return payload, False + return payload, False, doi if not allow_crossref or not settings.crossref_enabled: - return payload, False + return payload, False, doi crossref_doi = await discover_doi_for_publication( item=item, max_rows=settings.crossref_max_rows, email=email, ) if crossref_doi is None or crossref_doi == doi: - return payload, crossref_doi is not None + return payload, crossref_doi is not None, doi or crossref_doi crossref_payload = await _fetch_unpaywall_payload_by_doi( client=client, doi=crossref_doi, email=email, ) if crossref_payload is not None: - return crossref_payload, True - return payload, True + return crossref_payload, True, crossref_doi + return payload, True, crossref_doi async def _doi_and_pdf_from_payload( @@ -265,10 +241,11 @@ def _outcome_with_failure( item: PublicationListItem, failure_reason: str, used_crossref: bool, + doi_override: str | None = None, ) -> OaResolutionOutcome: return OaResolutionOutcome( publication_id=item.publication_id, - doi=_publication_doi(item), + doi=normalize_doi(doi_override) if doi_override is not None else _publication_doi(item), pdf_url=None, failure_reason=failure_reason, source=None, @@ -310,10 +287,7 @@ def _resolved_pdf_count(outcomes: dict[int, OaResolutionOutcome]) -> int: def _outcome_metadata(outcomes: dict[int, OaResolutionOutcome]) -> dict[int, tuple[str | None, str | None]]: - return { - publication_id: (outcome.doi, outcome.pdf_url) - for publication_id, outcome in outcomes.items() - } + return {publication_id: (outcome.doi, outcome.pdf_url) for publication_id, outcome in outcomes.items()} async def _resolve_outcome_for_item( @@ -323,7 +297,7 @@ async def _resolve_outcome_for_item( email: str, allow_crossref: bool, ) -> OaResolutionOutcome: - payload, used_crossref = await _resolve_item_payload( + payload, used_crossref, resolved_doi = await _resolve_item_payload( client=client, item=item, email=email, @@ -334,6 +308,7 @@ async def _resolve_outcome_for_item( item=item, failure_reason=_missing_payload_failure_reason(item, used_crossref=used_crossref), used_crossref=used_crossref, + doi_override=resolved_doi, ) doi, pdf_url = await _doi_and_pdf_from_payload(payload, client=client) return _outcome_from_payload( @@ -367,13 +342,8 @@ async def _safe_outcome_for_item( allow_crossref=allow_crossref, ) except Exception as exc: # pragma: no cover - defensive network boundary - logger.warning( - "unpaywall.resolve_item_failed", - extra={ - "event": "unpaywall.resolve_item_failed", - "publication_id": item.publication_id, - "error": str(exc), - }, + structured_log( + logger, "warning", "unpaywall.resolve_item_failed", publication_id=item.publication_id, error=str(exc) ) return _outcome_with_failure( item=item, @@ -423,24 +393,28 @@ async def resolve_publication_oa_outcomes( return {} email = _email_for_request(request_email) if email is None: - logger.debug("unpaywall.resolve_skipped_missing_email") + structured_log(logger, "debug", "unpaywall.resolve_skipped_missing_email") return {} import httpx timeout_seconds = max(float(settings.unpaywall_timeout_seconds), 0.5) targets = _resolution_targets(items)[: max(int(settings.unpaywall_max_items_per_request), 0)] - async with httpx.AsyncClient(timeout=timeout_seconds) as client: + headers = {"User-Agent": f"scholar-scraper/1.0 (mailto:{email})"} + async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True, headers=headers) as client: outcomes = await _resolve_outcomes_with_client( client=client, targets=targets, email=email, ) - _log_resolution_summary( + structured_log( + logger, + "info", + "unpaywall.resolve_completed", publication_count=len(items), doi_input_count=_doi_input_count(items), search_attempt_count=_search_attempt_count(targets=targets), resolved_pdf_count=_resolved_pdf_count(outcomes), - email=email, + email_domain=email.split("@", 1)[-1] if "@" in email else None, ) return outcomes diff --git a/app/services/domains/unpaywall/pdf_discovery.py b/app/services/unpaywall/pdf_discovery.py similarity index 98% rename from app/services/domains/unpaywall/pdf_discovery.py rename to app/services/unpaywall/pdf_discovery.py index 5f82a58..01d568d 100644 --- a/app/services/domains/unpaywall/pdf_discovery.py +++ b/app/services/unpaywall/pdf_discovery.py @@ -1,10 +1,10 @@ from __future__ import annotations -from html.parser import HTMLParser import re +from html.parser import HTMLParser from urllib.parse import urljoin, urlparse -from app.services.domains.unpaywall.rate_limit import wait_for_unpaywall_slot +from app.services.unpaywall.rate_limit import wait_for_unpaywall_slot from app.settings import settings PDF_MIME = "application/pdf" diff --git a/app/services/domains/unpaywall/rate_limit.py b/app/services/unpaywall/rate_limit.py similarity index 100% rename from app/services/domains/unpaywall/rate_limit.py rename to app/services/unpaywall/rate_limit.py diff --git a/app/services/domains/settings/__init__.py b/app/services/users/__init__.py similarity index 100% rename from app/services/domains/settings/__init__.py rename to app/services/users/__init__.py diff --git a/app/services/domains/users/application.py b/app/services/users/application.py similarity index 95% rename from app/services/domains/users/application.py rename to app/services/users/application.py index 7f019c4..e07f514 100644 --- a/app/services/domains/users/application.py +++ b/app/services/users/application.py @@ -39,9 +39,7 @@ async def get_user_by_id(db_session: AsyncSession, user_id: int) -> User | None: async def get_user_by_email(db_session: AsyncSession, email: str) -> User | None: - result = await db_session.execute( - select(User).where(User.email == normalize_email(email)) - ) + result = await db_session.execute(select(User).where(User.email == normalize_email(email))) return result.scalar_one_or_none() @@ -95,4 +93,3 @@ async def set_user_password_hash( await db_session.commit() await db_session.refresh(user) return user - diff --git a/app/settings.py b/app/settings.py index 43871cf..c9098a1 100644 --- a/app/settings.py +++ b/app/settings.py @@ -1,5 +1,5 @@ -from dataclasses import dataclass import os +from dataclasses import dataclass DEFAULT_SECURITY_PERMISSIONS_POLICY = ( "accelerometer=(), autoplay=(), camera=(), display-capture=(), " @@ -140,6 +140,11 @@ class Settings: "INGESTION_RETRY_BACKOFF_SECONDS", 1.0, ) + ingestion_rate_limit_retries: int = _env_int("INGESTION_RATE_LIMIT_RETRIES", 3) + ingestion_rate_limit_backoff_seconds: float = _env_float( + "INGESTION_RATE_LIMIT_BACKOFF_SECONDS", + 30.0, + ) ingestion_max_pages_per_scholar: int = _env_int( "INGESTION_MAX_PAGES_PER_SCHOLAR", 30, @@ -182,6 +187,7 @@ class Settings: 6, ) scheduler_queue_batch_size: int = _env_int("SCHEDULER_QUEUE_BATCH_SIZE", 10) + scheduler_pdf_queue_batch_size: int = _env_int("SCHEDULER_PDF_QUEUE_BATCH_SIZE", 15) frontend_enabled: bool = _env_bool("FRONTEND_ENABLED", True) frontend_dist_dir: str = _env_str("FRONTEND_DIST_DIR", "/app/frontend/dist") scholar_image_upload_dir: str = _env_str( @@ -229,6 +235,13 @@ class Settings: "SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD", 3, ) + scholar_http_user_agent: str = _env_str("SCHOLAR_HTTP_USER_AGENT", "") + scholar_http_rotate_user_agent: bool = _env_bool("SCHOLAR_HTTP_ROTATE_USER_AGENT", False) + scholar_http_accept_language: str = _env_str( + "SCHOLAR_HTTP_ACCEPT_LANGUAGE", + "en-US,en;q=0.9", + ) + scholar_http_cookie: str = _env_str("SCHOLAR_HTTP_COOKIE", "") unpaywall_enabled: bool = _env_bool("UNPAYWALL_ENABLED", True) unpaywall_email: str = _env_str("UNPAYWALL_EMAIL", "") unpaywall_timeout_seconds: float = _env_float("UNPAYWALL_TIMEOUT_SECONDS", 4.0) @@ -241,17 +254,29 @@ class Settings: ) pdf_auto_retry_first_interval_seconds: int = _env_int( "PDF_AUTO_RETRY_FIRST_INTERVAL_SECONDS", - 3_600, + 86_400, ) - pdf_auto_retry_max_attempts: int = _env_int("PDF_AUTO_RETRY_MAX_ATTEMPTS", 3) + pdf_auto_retry_max_attempts: int = _env_int("PDF_AUTO_RETRY_MAX_ATTEMPTS", 2) unpaywall_pdf_discovery_enabled: bool = _env_bool("UNPAYWALL_PDF_DISCOVERY_ENABLED", True) unpaywall_pdf_discovery_max_candidates: int = _env_int("UNPAYWALL_PDF_DISCOVERY_MAX_CANDIDATES", 5) unpaywall_pdf_discovery_max_html_bytes: int = _env_int("UNPAYWALL_PDF_DISCOVERY_MAX_HTML_BYTES", 500_000) + arxiv_enabled: bool = _env_bool("ARXIV_ENABLED", True) + arxiv_timeout_seconds: float = _env_float("ARXIV_TIMEOUT_SECONDS", 3.0) + arxiv_min_interval_seconds: float = _env_float("ARXIV_MIN_INTERVAL_SECONDS", 4.0) + arxiv_rate_limit_cooldown_seconds: float = _env_float("ARXIV_RATE_LIMIT_COOLDOWN_SECONDS", 60.0) + arxiv_default_max_results: int = _env_int("ARXIV_DEFAULT_MAX_RESULTS", 3) + arxiv_cache_ttl_seconds: float = _env_float("ARXIV_CACHE_TTL_SECONDS", 900.0) + arxiv_cache_max_entries: int = _env_int("ARXIV_CACHE_MAX_ENTRIES", 512) + arxiv_mailto: str = _env_str("ARXIV_MAILTO", "") crossref_enabled: bool = _env_bool("CROSSREF_ENABLED", True) crossref_max_rows: int = _env_int("CROSSREF_MAX_ROWS", 10) crossref_timeout_seconds: float = _env_float("CROSSREF_TIMEOUT_SECONDS", 8.0) crossref_min_interval_seconds: float = _env_float("CROSSREF_MIN_INTERVAL_SECONDS", 0.6) crossref_max_lookups_per_request: int = _env_int("CROSSREF_MAX_LOOKUPS_PER_REQUEST", 8) + openalex_api_key: str | None = os.getenv("OPENALEX_API_KEY") + crossref_api_token: str | None = os.getenv("CROSSREF_API_TOKEN") + crossref_api_mailto: str | None = os.getenv("CROSSREF_API_MAILTO") + settings = Settings() diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index f6b8216..0000000 --- a/docs/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Documentation Index - -This directory contains both docs content and docs tooling. - -## Audience-Based Structure - -- `docs/user/`: onboarding and usage-oriented docs for self-hosters. -- `docs/operations/`: runbooks and rollout checklists for ongoing operations. -- `docs/developer/`: architecture, contribution standards, and local development workflows. -- `docs/reference/`: canonical API and environment contracts. - -## Docs Tooling - -- `docs/website/`: Docusaurus app used to publish docs to GitHub Pages. - -## Recommended Reading Paths - -- Users: `docs/user/getting-started.md` -- Operators: `docs/operations/overview.md` -- Developers: `docs/developer/overview.md` -- Contract consumers: `docs/reference/overview.md` - -Published site: https://justinzeus.github.io/scholarr/ diff --git a/docs/assets/.gitkeep b/docs/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index 0a169e6..392670e 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -1,25 +1,173 @@ -# Domain Boundaries +--- +title: Architecture +sidebar_position: 2 +--- + +# Architecture ## Data Model Rules -- Scholar tracking is user-scoped. -- Publications are global/deduplicated records. -- Read/favorite/visibility state stays on scholar-publication link rows. +- **Scholar tracking is user-scoped.** Each user manages their own list of tracked scholars. Validate mapping/join tables; never assume global links between users and Scholar IDs. +- **Publications are global, deduplicated records.** Deduplicate via Scholar cluster ID and normalized fingerprinting prior to database insertion. +- **State lives on the link.** Read/unread, favorites, and visibility state exist exclusively on the scholar-publication link table, not the global publication table. -## Service Boundaries +## Domain Service Boundaries -Canonical business logic belongs in `app/services/domains/*`. +All business logic resides in `app/services//`. Flat files in the `app/services/` root are strictly prohibited. -- `app/services/domains/ingestion/*`: run orchestration, continuation queue, scheduler, and scrape safety. -- `app/services/domains/scholar/*`: fail-fast scholar parsing and source fetch adapters. -- `app/services/domains/scholars/*`: scholar CRUD, profile image, and name-search controls. -- `app/services/domains/publications/*`: listing/read-state, favorite toggles, enrichment scheduling, and retry paths. -- `app/services/domains/crossref/*` + `app/services/domains/unpaywall/*`: DOI/OA enrichment with bounded pacing. -- `app/services/domains/runs/*`: run history and continuation queue operations. -- `app/services/domains/portability/*`: import/export workflows. +### Ingestion (`app/services/ingestion/`) -## Frontend Behavior Notes +Run orchestration, continuation queue, scheduler integration, and scrape safety policy enforcement. The `ScholarIngestionService` drives the primary data acquisition loop. -- Mobile primary nav is in a left drawer and closes on route change or logout. -- Long list views use internal scroll containers. -- Name search remains intentionally constrained due upstream anti-bot behavior; production onboarding should prefer scholar ID/profile URL. +Key modules: +- `application.py` - Main ingestion orchestrator +- `scheduler.py` - Background tick loop, queue batch processing +- `constants.py` - Safety policy constants and floor values +- `fingerprints.py` - Publication fingerprinting for deduplication +- `types.py` - Ingestion result types and state enums + +### Scholar Parsing (`app/services/scholar/`) + +Fail-fast Google Scholar HTML parsing and source fetch adapters. Handles paginated HTML feeds, extracts publication blocks via regex and DOM invariants (e.g., `gsc_vcd_cib`). + +Key modules: +- `parser.py` - HTML parser for publication extraction +- `parser_utils.py` - Parsing helpers and DOM selectors +- `source.py` - HTTP fetch adapters with browser headers +- `profile_rows.py` - Profile metadata extraction +- `author_rows.py` - Author citation row parsing +- `state_detection.py` - Blocked/CAPTCHA/rate-limit detection + +### Scholar Management (`app/services/scholars/`) + +Scholar CRUD, profile image management, and name-search controls with rate limiting. + +Key modules: +- `application.py` - Scholar lifecycle operations +- `uploads.py` - Image upload handling +- `search_hints.py` - Name search with caching and cooldowns +- `validators.py` - Input validation for scholar creation + +### Publications (`app/services/publications/`) + +Listing, read-state management, favorite toggles, enrichment scheduling, deduplication, and PDF queue management. + +Key modules: +- `application.py` - Publication service facade +- `listing.py` - Filtered listing with pagination (modes: all/unread/latest) +- `queries.py` - Database query builders +- `counts.py` - Aggregation counts for dashboard +- `dedup.py` - Duplicate detection and merging +- `enrichment.py` - Identifier and metadata enrichment orchestration +- `pdf_queue.py` - PDF resolution queue policy +- `pdf_resolution_pipeline.py` - Multi-source PDF resolution (Unpaywall, arXiv) +- `types.py` - Publication DTOs and response types + +### Publication Identifiers (`app/services/publication_identifiers/`) + +Multi-identifier resolution engine. A single publication can have multiple identifiers (DOI, arXiv ID, PMID, PMCID). This decoupled approach replaced the earlier hardcoded DOI-only model. + +Key modules: +- `application.py` - Identifier gathering orchestration +- `normalize.py` - Identifier normalization and validation + +### arXiv (`app/services/arxiv/`) + +Typed API client with global DB-backed throttle, query cache, and in-flight request coalescing. + +Key modules: +- `client.py` - HTTP client for arXiv export API +- `gateway.py` - Gateway with advisory lock, caching, and coalescing +- `cache.py` - Query cache with TTL and max-entry pruning +- `rate_limit.py` - Global rate limiter via `arxiv_runtime_state` table +- `guards.py` - Load-shedding guards (skip when DOI/arXiv evidence exists) +- `parser.py` - Atom XML response parser + +Safety features: +- Requests are globally serialized via a PostgreSQL advisory lock and shared runtime row (`arxiv_runtime_state`) +- Identical request payloads are fingerprinted and cached in `arxiv_query_cache_entries` with TTL + max-entry pruning +- Concurrent identical misses are coalesced in-process (one outbound call serves all waiters) + +### Crossref (`app/services/crossref/`) + +DOI lookup via Crossref REST API with bounded pacing and configurable batch limits. + +### Unpaywall (`app/services/unpaywall/`) + +Open-access PDF resolution via Unpaywall API, with HTML-based PDF link discovery as a fallback. + +Key modules: +- `application.py` - Unpaywall service facade +- `pdf_discovery.py` - HTML page scraping for PDF link candidates + +### OpenAlex (`app/services/openalex/`) + +Metadata matching via OpenAlex API for supplementary identifier resolution. + +Key modules: +- `client.py` - OpenAlex API client +- `matching.py` - Fuzzy title/author matching + +### Runs (`app/services/runs/`) + +Run history tracking and continuation queue operations. + +Key modules: +- `application.py` - Run lifecycle management +- `queue_service.py` - Continuation queue operations (retry, drop, clear) +- `queue_queries.py` - Queue item queries + +### Portability (`app/services/portability/`) + +Import/export workflows for scholar data with full publication state preservation. + +Key modules: +- `application.py` - Import/export orchestration +- `exporting.py` - Scholar export serialization +- `publication_import.py` - Publication import with deduplication +- `scholar_import.py` - Scholar import with link reconstruction +- `normalize.py` - Payload normalization and validation + +### Database Operations (`app/services/dbops/`) + +Integrity checking, link repair, and near-duplicate repair operations exposed via admin API and CLI scripts. + +Key modules: +- `__init__.py` - `collect_integrity_report`, `run_publication_link_repair` +- `near_duplicate_repair.py` - Near-duplicate publication detection and merging + +## Data Integration Flow + +```mermaid +graph TD + UI[Frontend Vue App] --> API[FastAPI Backend] + API --> Scheduler[Background Async Scheduler] + + Scheduler -->|1. Fetch HTML| Scholar[Google Scholar HTML Parser] + Scholar -->|2. Extract Metadata| IdentifierModule[Identifier Gathering] + + IdentifierModule -->|Search arXiv API| API1(arXiv) + IdentifierModule -->|Search Crossref API| API2(Crossref) + + IdentifierModule -->|3. Save Identifiers| DB[(PostgreSQL)] + + Scheduler -->|4. Resolve PDF| PDFPipeline[PDF Resolution Pipeline] + DB --> |Identified DOIs| PDFPipeline + PDFPipeline -->|Search Open Access APIs| Unpaywall(Unpaywall API) + + Unpaywall --> |Acquire PDF URL| PDFWorker[PDF Download Worker] + PDFWorker --> |Store PDF Metadata| DB +``` + +## API Layer + +Routes live in `app/api/routers/`. All responses under `/api/v1` use a strict envelope format. See [API Reference](../reference/api.md) for the full contract. + +## Middleware Stack + +Applied in `app/main.py`: + +1. **CSRF Protection** - Token-based CSRF validation +2. **Session Middleware** - Cookie-based session management (itsdangerous signing) +3. **Request Logging** - Structured request/response logging with configurable skip paths +4. **Security Headers** - Configurable HTTP security headers and CSP diff --git a/docs/developer/contributing.md b/docs/developer/contributing.md index ab44797..ec5ad5e 100644 --- a/docs/developer/contributing.md +++ b/docs/developer/contributing.md @@ -1,24 +1,78 @@ +--- +title: Contributing +sidebar_position: 4 +--- + # Contributing -## Scope -This project favors small, reviewable pull requests that keep runtime behavior clear and operationally safe. +## PR Process -## Essential-File Policy -Commit only source-of-truth files required to build, run, test, or document the app. +1. Create a feature branch from `main`. +2. Make your changes following the code standards below. +3. Run tests inside the container (see [Testing](testing.md)). +4. Run `ruff check .` and `mypy app/` to catch lint and type errors. +5. Open a pull request with a clear title and description. -Do not commit generated or local-only artifacts, including: -- `__pycache__/`, `*.pyc`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/` -- frontend build/install outputs like `frontend/dist/`, `frontend/node_modules/`, `frontend/.vite/` -- coverage outputs (`.coverage`, `htmlcov/`) -- packaging/build leftovers (`*.egg-info/`, `build/`, `dist/`) -- local probe/scratch material (`planning/`) +## Commit Conventions -CI enforces this with `scripts/check_no_generated_artifacts.sh`. +This project uses [Conventional Commits](https://www.conventionalcommits.org/) with [python-semantic-release](https://python-semantic-release.readthedocs.io/) for automated versioning. -## Merge Checklist -- [ ] Changes are minimal, purposeful, and remove obsolete/dead code in touched areas. -- [ ] Backend tests pass (`uv run pytest tests/unit` and integration scope as needed). -- [ ] Frontend checks pass (`npm run typecheck`, `npm run test:run`, `npm run build`). -- [ ] API/behavior docs are updated when env vars, endpoints, or payloads change. -- [ ] `README.md`, `.env.example`, and deployment notes stay aligned. -- [ ] `scripts/check_no_generated_artifacts.sh` passes locally. +Commit message format: + +``` +(): + +[optional body] +``` + +Types: `feat`, `fix`, `docs`, `ci`, `refactor`, `test`, `chore`, `perf`. + +Examples: +- `feat(scholars): add bulk import from CSV` +- `fix(ingestion): handle empty citation blocks` +- `docs: update configuration reference` + +## Code Standards + +### Function Length + +Maximum 50 lines per function. Break complex logic into small, testable, single-responsibility functions. + +### DRY + +Abstract repetitive logic immediately. No duplicate boilerplate for database queries, API responses, or error handling. + +### Negative Space Programming + +Use explicit assertions and constraints to define invalid states. Fail fast and early. Do not allow silent failures or cascading malformed data, especially in DOM parsing. + +### Cyclomatic Complexity + +Flatten logic. Use early returns and guard clauses instead of deep nesting. No magic numbers. + +### Domain Service Boundaries + +All business logic resides in `app/services//`. Flat files in the `app/services/` root are strictly prohibited. Each domain owns its own application service, types, and helpers. + +### API Envelope + +All `/api/v1` responses use the strict envelope format: + +- Success: `{"data": ..., "meta": {"request_id": "..."}}` +- Error: `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` + +### Data Isolation + +- Scholar tracking is user-scoped. +- Publications are global, deduplicated records. +- Read/favorite/visibility state lives on scholar-publication link rows. + +### Scrape Safety + +Rate limits and cooldowns are immutable constraints. They prevent IP bans and must not be optimized away or set to zero. + +## UI Standards + +- Integrate Tailwind with the preset theming system (`frontend/src/theme/presets/`). +- Every UI element must have a clear purpose. +- Clarity through both styling and language. diff --git a/docs/developer/documentation-standards.md b/docs/developer/documentation-standards.md deleted file mode 100644 index f3c25b9..0000000 --- a/docs/developer/documentation-standards.md +++ /dev/null @@ -1,26 +0,0 @@ -# Documentation Standards - -This project keeps docs organized by audience and document type. - -## Audience Split - -- `user/`: onboarding and usage tasks for self-hosted users. -- `operations/`: runbooks and checklists for production operation. -- `developer/`: architecture, contribution workflow, and implementation guides. -- `reference/`: stable contracts (API, env variables, configuration behavior). - -## Document Quality Rules - -- One primary audience per page. -- Task pages must include prerequisites, exact commands, and verification steps. -- Reference pages should be contract-first and avoid procedural noise. -- Runbooks should include recovery steps and rollback/safety notes. -- Prefer concise pages with strong cross-links over long mixed-purpose pages. - -## Required Top-Level Entrypoints - -- `index.md`: user/developer/operator navigation hub. -- `user/getting-started.md`: first-run path. -- `developer/local-development.md`: contributor setup and validation path. -- `operations/overview.md`: operational playbook index. -- `reference/overview.md`: contract index. diff --git a/docs/developer/frontend-theme-inventory.md b/docs/developer/frontend-theme-inventory.md index b8d6571..0233071 100644 --- a/docs/developer/frontend-theme-inventory.md +++ b/docs/developer/frontend-theme-inventory.md @@ -1,89 +1,105 @@ -# Theme Inventory (Phase 0) +--- +title: Frontend Theme Inventory +sidebar_position: 6 +--- -This file captures the semantic color system baseline for the theme refactor. +# Frontend Theme Inventory ## Token Domains -- `scale`: - - `brand` (`50..950`) - - `info` (`50..950`) - - `success` (`50..950`) - - `warning` (`50..950`) - - `danger` (`50..950`) -- `surface`: - - `app` - - `nav` - - `nav_active` - - `card` - - `card_muted` - - `table` - - `table_header` - - `input` - - `overlay` -- `text`: - - `primary` - - `secondary` - - `muted` - - `inverse` - - `link` -- `border`: - - `default` - - `strong` - - `subtle` - - `interactive` -- `focus`: - - `ring` - - `ring_offset` -- `action` variants (`primary`, `secondary`, `ghost`, `danger`): - - `bg` - - `border` - - `text` - - `hover_bg` - - `hover_border` - - `hover_text` -- `state` variants (`info`, `success`, `warning`, `danger`): - - `bg` - - `border` - - `text` +The theme system uses semantic tokens organized into domains: -## Theme Sources +### Scale Colors -Theme presets are dynamically loaded from `frontend/src/theme/presets/*.{json,js}`. +Color ramps from `50` to `950` for each intent: -Current preset files: +- `brand` - Primary brand color +- `info` - Informational elements +- `success` - Success states +- `warning` - Warning states +- `danger` - Error/destructive states -- `frontend/src/theme/presets/parchment.js` -- `frontend/src/theme/presets/lilac.js` -- `frontend/src/theme/presets/dune.js` -- `frontend/src/theme/presets/oatmeal.js` -- `frontend/src/theme/presets/scholarly.json` -- `frontend/src/theme/presets/graphite.json` -- `frontend/src/theme/presets/tide.json` +### Surface -Each preset contains both `light` and `dark` mode token definitions. +Background colors for major UI areas: -## Adoption Status (Phase 0-3 complete baseline) +| Token | Usage | +|-------|-------| +| `app` | Application background | +| `nav` | Navigation bar | +| `nav_active` | Active navigation item | +| `card` | Card backgrounds | +| `card_muted` | Muted card variant | +| `table` | Table body | +| `table_header` | Table header | +| `input` | Form inputs | +| `overlay` | Modal/dialog overlays | -Tokenized foundation components: +### Text -- `AppButton` -- `AppCard` -- `AppCheckbox` -- `AppEmptyState` -- `AppHelpHint` -- `AppInput` -- `AppSelect` -- `AppTable` -- `AppModal` -- `AppHeader` -- `AppNav` -- `AppAlert` -- `AppBadge` -- `RunStatusBadge` -- `QueueHealthBadge` +| Token | Usage | +|-------|-------| +| `primary` | Default text | +| `secondary` | Supporting text | +| `muted` | Disabled/placeholder text | +| `inverse` | Text on dark backgrounds | +| `link` | Hyperlinks | -Hardening in place: +### Border -- Frontend token policy check script: `frontend/scripts/check_theme_tokens.mjs` -- CI enforcement step in `frontend-quality` workflow -- Theme preset integrity tests in `frontend/src/theme/presets.test.ts` +| Token | Usage | +|-------|-------| +| `default` | Standard borders | +| `strong` | Emphasized borders | +| `subtle` | Light separators | +| `interactive` | Hover/focus borders | + +### Focus + +| Token | Usage | +|-------|-------| +| `ring` | Focus ring color | +| `ring_offset` | Focus ring offset color | + +### Action Variants + +Each action type (`primary`, `secondary`, `ghost`, `danger`) provides: + +`bg`, `border`, `text`, `hover_bg`, `hover_border`, `hover_text` + +### State Variants + +Each state (`info`, `success`, `warning`, `danger`) provides: + +`bg`, `border`, `text` + +## Theme Presets + +Presets are loaded from `frontend/src/theme/presets/*.{json,js}`. Each contains both `light` and `dark` mode token definitions. + +Current presets: + +| Preset | Format | +|--------|--------| +| `parchment` | JS | +| `lilac` | JS | +| `dune` | JS | +| `oatmeal` | JS | +| `scholarly` | JSON | +| `graphite` | JSON | +| `tide` | JSON | + +## Tokenized Components + +Foundation components using the token system: + +- `AppButton`, `AppCard`, `AppCheckbox`, `AppEmptyState` +- `AppHelpHint`, `AppInput`, `AppSelect`, `AppTable` +- `AppModal`, `AppHeader`, `AppNav`, `AppAlert` +- `AppBadge`, `RunStatusBadge`, `QueueHealthBadge` + +## Enforcement + +- Token policy check: `frontend/scripts/check_theme_tokens.mjs` +- CI step in `frontend-quality` workflow +- Preset integrity tests: `frontend/src/theme/presets.test.ts` diff --git a/docs/developer/ingestion.md b/docs/developer/ingestion.md new file mode 100644 index 0000000..12eb014 --- /dev/null +++ b/docs/developer/ingestion.md @@ -0,0 +1,89 @@ +--- +title: Ingestion Pipeline +sidebar_position: 5 +--- + +# Ingestion Pipeline + +The `ScholarIngestionService` drives the primary data acquisition loop. Google Scholar uses heavy bot protection, so the pipeline includes nuanced backoff strategies to protect user networks from IP bans. + +## Pipeline Overview + +1. The scheduler (or a manual trigger) starts a **run** for one or more scholars. +2. The service connects via HTTPX with strict browser headers. +3. Paginated HTML feeds are downloaded for each scholar profile. +4. A regex + DOM-invariant parser (`gsc_vcd_cib` selectors) extracts publication blocks. +5. Publications are fingerprinted and deduplicated against the global store. +6. External APIs resolve additional identifiers. +7. The PDF resolution pipeline runs asynchronously for publications with known DOIs. + +## Rate Limiting & Backoff + +### Network Errors + +Handled via `INGESTION_NETWORK_ERROR_RETRIES` (default: 1) with base backoff of `INGESTION_RETRY_BACKOFF_SECONDS` (default: 1.0s). + +### HTTP 429 (Rate Limited) + +When the parser detects `BLOCKED_OR_CAPTCHA` with `blocked_http_429_rate_limited`, a dedicated cooldown applies: + +- Retries: `INGESTION_RATE_LIMIT_RETRIES` (default: 3) +- Backoff per retry: `INGESTION_RATE_LIMIT_BACKOFF_SECONDS` (default: 30s) + +This pauses the pipeline gracefully instead of failing the entire run. + +### Safety Cooldowns + +Threshold-based cooldowns halt all ingestion after repeated failures: + +| Threshold | Variable | Default | Cooldown | +|-----------|----------|---------|----------| +| Blocked failures | `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` | 1 | 1800s (30 min) | +| Network failures | `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` | 2 | 900s (15 min) | + +## Continuation Queue + +Multi-page ingestion uses a continuation queue to spread load over time: + +- `INGESTION_CONTINUATION_QUEUE_ENABLED` (default: `1`) +- Base delay: `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` (default: 120s) +- Max delay: `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` (default: 3600s) +- Max attempts: `INGESTION_CONTINUATION_MAX_ATTEMPTS` (default: 6) + +Each continuation item is re-enqueued with exponential backoff. + +## Identifier Resolution + +After publication extraction, the `gather_identifiers_for_publication` module resolves identifiers: + +1. **Local parsing** - Searches HTML parameters for DOI patterns and arXiv regex matches. +2. **arXiv API** - Queries `export.arxiv.org/api/query` by title and author strings. +3. **Crossref API** - Queries Crossref REST API by title and author strings. + +Identifiers are stored in the `publication_identifiers` table rather than as hardcoded properties, maximizing matching resilience for the PDF resolution stage. + +## PDF Resolution + +Publications with resolved DOIs enter the PDF resolution pipeline: + +1. **Unpaywall** - Queries the Unpaywall API for open-access PDF URLs. +2. **PDF Discovery** - If Unpaywall returns an OA page URL without a direct PDF link, the service fetches the HTML and searches for PDF link candidates. +3. **arXiv Direct** - If an arXiv ID is known, the PDF URL is derived directly. + +Auto-retry is configured via `PDF_AUTO_RETRY_*` variables. + +## arXiv Request Controls + +- **Global throttle**: arXiv calls share `arxiv_runtime_state` so all workers respect one cooldown/interval clock. +- **Query cache**: Identical request parameters are fingerprinted and cached in `arxiv_query_cache_entries`. +- **In-flight coalescing**: Duplicate concurrent misses join one outbound request. +- **Load shedding**: arXiv lookups are skipped when high-confidence DOI/arXiv evidence already exists, or when title quality is below threshold. + +### Observability Events + +| Event | Description | +|-------|-------------| +| `arxiv.request_scheduled` | Emitted before a gated request. Includes `wait_seconds`, `cooldown_remaining_seconds`, `source_path`. | +| `arxiv.request_completed` | Emitted after response. Includes `status_code`, `wait_seconds`, `source_path`. | +| `arxiv.cooldown_activated` | Emitted when status `429` triggers cooldown. | +| `arxiv.cache_hit` / `arxiv.cache_miss` | Emitted on query cache lookup with `source_path`. | diff --git a/docs/developer/local-development.md b/docs/developer/local-development.md index af89f3f..2adfab0 100644 --- a/docs/developer/local-development.md +++ b/docs/developer/local-development.md @@ -1,52 +1,100 @@ -# Developer Local Development +--- +title: Local Development +sidebar_position: 3 +--- -## Start the Dev Stack +# Local Development + +## Prerequisites + +- Docker and Docker Compose v2+ +- Python 3.12+ (for IDE support and local linting) +- Node.js 20+ (for frontend development) + +## Starting the Dev Stack + +The development compose file overlays the production config with hot-reload and a separate Vite dev server: ```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build +docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build ``` -Open: -- API: `http://localhost:8000` -- Frontend dev server: `http://localhost:5173` +This starts three services: -Stop: +| Service | Port | Description | +|---------|------|-------------| +| `db` | 5432 (internal) | PostgreSQL 15 | +| `app` | 8000 | FastAPI backend with `APP_RELOAD=1` | +| `frontend` | 5173 | Vite dev server proxying API calls to `app:8000` | + +### Dev-Specific Overrides + +The `docker-compose.dev.yml` file applies these changes: + +- **`app`**: Uses `scholarr-dev:local` image built from the `dev` stage. Mounts the project root as `/app` for hot reload. Disables `SESSION_COOKIE_SECURE` and the built frontend. +- **`frontend`**: Node 20 container running `npm install && npm run dev`. Mounts `./frontend` with a named volume for `node_modules`. Uses polling for file watching (`CHOKIDAR_USEPOLLING=1`). + +## Environment Setup ```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml down +cp .env.example .env ``` -## Backend Validation +Set at minimum: ```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app uv run pytest tests/unit -docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app uv run pytest -m integration +POSTGRES_PASSWORD=localdev +SESSION_SECRET_KEY=local-dev-secret-at-least-32-characters +SESSION_COOKIE_SECURE=0 ``` -## Frontend Validation +## Running Tests + +All tests run inside containers: ```bash -cd frontend -npm install -npm run typecheck -npm run test:run -npm run build +# Run unit tests (default: excludes integration markers) +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + python -m pytest + +# Run integration tests +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + python -m pytest -m integration + +# Run a specific test file +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + python -m pytest tests/unit/test_fingerprints.py -v ``` -## Repository Gates +See [Testing](testing.md) for markers, fixtures, and conventions. + +## Linting and Type Checking ```bash -python3 scripts/check_frontend_api_contract.py -python3 scripts/check_env_contract.py -./scripts/check_no_generated_artifacts.sh +# Ruff linting +ruff check . + +# Ruff formatting check +ruff format --check . + +# Mypy type checking +mypy app/ ``` -## Docs Site (Contributor Workflow) +Ruff config is in `pyproject.toml`: target Python 3.12, line length 120, rules `E F W I UP B SIM RUF`. -Docs tooling is colocated in `docs/website/`: +## Database Migrations + +Alembic migrations run automatically on startup when `MIGRATE_ON_START=1`. To run manually: ```bash -cd docs/website -npm install -npm run build +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + alembic upgrade head +``` + +To create a new migration: + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + alembic revision --autogenerate -m "description of change" ``` diff --git a/docs/developer/overview.md b/docs/developer/overview.md index 4d9880e..9974236 100644 --- a/docs/developer/overview.md +++ b/docs/developer/overview.md @@ -1,9 +1,65 @@ -# Developer Documentation +--- +title: Developer Overview +sidebar_position: 1 +--- -Use this section if you are contributing code or running full quality gates. +# Developer Overview -- [Documentation Standards](./documentation-standards.md) -- [Local Development](./local-development.md) -- [Architecture Boundaries](./architecture.md) -- [Contributing](./contributing.md) -- [Frontend Theme Inventory](./frontend-theme-inventory.md) +Scholarr is a Python 3.12+ FastAPI backend with an async SQLAlchemy ORM layer, PostgreSQL database, and a Vue 3 + TypeScript + Vite frontend. + +## Stack + +| Layer | Technology | +|-------|------------| +| Backend | Python 3.12+, FastAPI, SQLAlchemy 2.0 (async/asyncpg), Alembic | +| Frontend | TypeScript, Vue 3, Vite, Tailwind CSS | +| Database | PostgreSQL 15 | +| Infrastructure | Multi-stage Docker, Docker Compose | +| Linting | ruff (E, F, W, I, UP, B, SIM, RUF), mypy | +| Testing | pytest, pytest-asyncio | +| Versioning | python-semantic-release, conventional commits | + +## Quick Start + +```bash +# Build the dev image and start services +docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build + +# Backend: http://localhost:8000 (hot reload enabled) +# Frontend: http://localhost:5173 (Vite dev server with API proxy) +``` + +See [Local Development](local-development.md) for the full setup guide. + +## Project Layout + +``` +app/ +├── api/routers/ # FastAPI route handlers +├── auth/ # Session + CSRF middleware +├── db/ # SQLAlchemy models, session factory, migrations +├── services/ # Domain service modules (see Architecture) +│ ├── arxiv/ # arXiv API client, cache, rate limiting +│ ├── crossref/ # Crossref DOI lookups +│ ├── dbops/ # Database integrity + repair operations +│ ├── ingestion/ # Run orchestration, scheduler, safety gates +│ ├── openalex/ # OpenAlex metadata matching +│ ├── portability/ # Import/export workflows +│ ├── publication_identifiers/ # Multi-identifier resolution +│ ├── publications/ # Listing, enrichment, dedup, PDF queue +│ ├── runs/ # Run history, continuation queue +│ ├── scholar/ # HTML parser, source fetch adapters +│ ├── scholars/ # Scholar CRUD, image upload, name search +│ └── unpaywall/ # Unpaywall PDF discovery +├── main.py # App factory, lifespan, middleware stack +frontend/ +├── src/ +│ ├── components/ # Reusable Vue components +│ ├── theme/presets/ # Color theme presets (light + dark) +│ └── ... +scripts/db/ # Operational database scripts +tests/ +├── unit/ # Fast, no-database tests +├── integration/ # Tests requiring database + services +└── fixtures/ # Shared test fixtures +``` diff --git a/docs/developer/testing.md b/docs/developer/testing.md new file mode 100644 index 0000000..6440a3d --- /dev/null +++ b/docs/developer/testing.md @@ -0,0 +1,102 @@ +--- +title: Testing +sidebar_position: 7 +--- + +# Testing + +## Running Tests + +All tests must run inside containers: + +```bash +# Unit tests (default: excludes integration markers) +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + python -m pytest + +# Integration tests +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + python -m pytest -m integration + +# Specific marker +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + python -m pytest -m db + +# Verbose output for a specific file +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + python -m pytest tests/unit/test_fingerprints.py -v +``` + +## Test Configuration + +From `pyproject.toml`: + +```toml +[tool.pytest.ini_options] +addopts = "-q -m \"not integration\" --import-mode=importlib" +asyncio_mode = "auto" +testpaths = ["tests"] +``` + +- Default run excludes `integration` marked tests +- Uses `importlib` import mode to resolve module name collisions +- Async tests run automatically (no `@pytest.mark.asyncio` needed) + +## Markers + +| Marker | Description | +|--------|-------------| +| `integration` | Tests requiring external services (database, network) | +| `db` | Tests that validate database behavior and constraints | +| `migrations` | Tests focused on Alembic schema migration correctness | +| `schema` | Tests focused on multi-tenant schema invariants | +| `smoke` | Smoke tests for containerized runtime | + +## Test Tiers + +### Unit Tests (`tests/unit/`) + +Fast, no-database tests. Mock external dependencies. These run by default. + +Examples: +- `test_fingerprints.py` - Publication fingerprinting logic +- `test_scholar_parser.py` - HTML parsing without network calls +- `test_doi_normalize.py` - DOI normalization rules +- `test_ingestion_arxiv_rate_limit.py` - Rate limiter behavior +- `test_publication_pdf_resolution_pipeline.py` - PDF pipeline logic + +Domain-specific unit tests are organized under `tests/unit/services/domains/`: +- `arxiv/` - Cache, client, gateway, guards, parser, rate limit tests +- `openalex/` - Client and matching tests +- `publications/` - Dedup tests + +### Integration Tests (`tests/integration/`) + +Require a running database. Test full request/response flows and data consistency. + +Examples: +- `test_api_v1.py` - API endpoint integration tests +- `test_db_integrity.py` - Database integrity checks +- `test_run_lifecycle_consistency.py` - Run state machine transitions +- `test_deferred_enrichment.py` - Enrichment pipeline with real data +- `test_fixture_probe_runs.py` - Fixture-based run probes + +### Smoke Tests + +Marked with `@pytest.mark.smoke`. Validate the containerized runtime starts and serves basic requests. + +## Fixtures + +Test fixtures live in `tests/fixtures/`: + +``` +tests/fixtures/ +└── scholar/ + ├── profile_ok_amIMrIEAAAAJ.html # Successful profile HTML + └── regression/ + ├── profile_P1RwlvoAAAAJ.html # Regression case + ├── profile_LZ5D_p4AAAAJ.html # Regression case + └── profile_AAAAAAAAAAAA.html # Regression case +``` + +Scholar HTML fixtures are real Google Scholar profile pages used to test parser robustness against DOM structure changes. diff --git a/docs/index.md b/docs/index.md index 4a6574c..94b563b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,33 +1,27 @@ --- -slug: / +title: Scholarr Documentation +sidebar_position: 1 --- -# scholarr Documentation +# Scholarr -This documentation is organized for both users and developers. +Scholarr is a self-hosted academic publication tracker. It monitors Google Scholar profiles, discovers new publications, resolves open-access PDFs, and presents everything through a clean Vue 3 dashboard. -## For Users +## Quick Links -- [User Overview](./user/overview.md) -- [Getting Started](./user/getting-started.md) +| Section | Description | +|---------|-------------| +| [User Guide](user/overview.md) | What scholarr does, installation, configuration | +| [Developer Guide](developer/overview.md) | Architecture, local development, contributing | +| [Operations](operations/overview.md) | Deployment, database runbook, scrape safety | +| [Reference](reference/overview.md) | API contract, environment variables, changelog | -## For Operators +## Key Features -- [Operations Overview](./operations/overview.md) -- [Scrape Safety Runbook](./operations/scrape-safety-runbook.md) -- [Database Runbook](./operations/database-runbook.md) -- [Migration Rollout Checklist](./operations/migration-checklist.md) - -## For Developers - -- [Developer Overview](./developer/overview.md) -- [Local Development](./developer/local-development.md) -- [Architecture Boundaries](./developer/architecture.md) -- [Contributing](./developer/contributing.md) -- [Frontend Theme Inventory](./developer/frontend-theme-inventory.md) - -## Reference - -- [Reference Overview](./reference/overview.md) -- [API Contract](./reference/api-contract.md) -- [Environment Reference](./reference/environment.md) +- **Scholar Tracking** - Add Google Scholar profiles by ID, URL, or name search +- **Automated Ingestion** - Background scheduler fetches new publications on a configurable interval +- **Identifier Resolution** - Cross-references arXiv, Crossref, and OpenAlex for DOIs and metadata +- **PDF Discovery** - Resolves open-access PDFs via Unpaywall and arXiv +- **Import/Export** - Portable scholar data with full publication state +- **Multi-User** - Session-based auth with admin user management +- **Theming** - Multiple color presets with light/dark mode support diff --git a/docs/operations/arxiv-runbook.md b/docs/operations/arxiv-runbook.md new file mode 100644 index 0000000..b49da90 --- /dev/null +++ b/docs/operations/arxiv-runbook.md @@ -0,0 +1,80 @@ +--- +title: arXiv Runbook +sidebar_position: 5 +--- + +# arXiv Operations Runbook + +Use this runbook when arXiv lookups are slow, rate-limited, or behaving unexpectedly. + +## Signals to Check + +- Logs for `arxiv.request_scheduled`, `arxiv.request_completed`, `arxiv.cooldown_activated`, `arxiv.cache_hit`, `arxiv.cache_miss` +- `arxiv_runtime_state` row for cooldown and next-allowed timestamps +- `arxiv_query_cache_entries` size and expiry churn + +## Event Field Guide + +| Field | Description | +|-------|-------------| +| `wait_seconds` | Enforced pre-request delay from the global limiter | +| `status_code` | Upstream response code from arXiv | +| `cooldown_remaining_seconds` | Remaining cooldown when blocked or after 429 | +| `source_path` | Caller path (`search` or `lookup_ids`) | + +## Quick SQL Checks + +### Runtime State + +```sql +SELECT state_key, next_allowed_at, cooldown_until, updated_at +FROM arxiv_runtime_state; +``` + +### Cache Status + +```sql +SELECT count(*) AS cache_rows, + min(expires_at) AS earliest_expiry, + max(expires_at) AS latest_expiry +FROM arxiv_query_cache_entries; +``` + +## Common Scenarios + +### 1. Repeated `arxiv.cooldown_activated` Events + +- Confirm recent `429` statuses in `arxiv.request_completed` logs. +- Reduce caller pressure (check title-quality/identifier guards are active). +- Temporarily raise `ARXIV_RATE_LIMIT_COOLDOWN_SECONDS` if upstream remains strict. + +### 2. High Request Latency with Few Completions + +- Inspect `wait_seconds` in `arxiv.request_scheduled`. +- Verify only one process path is repeatedly hitting arXiv (`source_path`). +- Confirm cache is enabled (`ARXIV_CACHE_TTL_SECONDS > 0`) and effective (`cache_hit` appears). + +### 3. Low Cache Effectiveness + +- Validate normalized query behavior and caller churn. +- Increase `ARXIV_CACHE_TTL_SECONDS` for stable workloads. +- Increase `ARXIV_CACHE_MAX_ENTRIES` if heavy eviction is observed. + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `ARXIV_ENABLED` | `1` | Enable arXiv lookups | +| `ARXIV_TIMEOUT_SECONDS` | `3.0` | Request timeout | +| `ARXIV_MIN_INTERVAL_SECONDS` | `4.0` | Min interval between requests | +| `ARXIV_RATE_LIMIT_COOLDOWN_SECONDS` | `60.0` | Cooldown after 429 | +| `ARXIV_DEFAULT_MAX_RESULTS` | `3` | Max results per query | +| `ARXIV_CACHE_TTL_SECONDS` | `900` | Cache TTL (15 min) | +| `ARXIV_CACHE_MAX_ENTRIES` | `512` | Max cached queries | +| `ARXIV_MAILTO` | *(empty)* | Contact email for API headers | + +## Safe Recovery + +1. Pause automated ingestion if rate-limit storms persist. +2. Let cooldown expire naturally; avoid manual burst retries. +3. Resume and monitor event rates before restoring full load. diff --git a/docs/operations/database-runbook.md b/docs/operations/database-runbook.md index abe320d..434bc51 100644 --- a/docs/operations/database-runbook.md +++ b/docs/operations/database-runbook.md @@ -1,100 +1,195 @@ -# Database Operations Runbook +--- +title: Database Runbook +sidebar_position: 3 +--- -This runbook defines backup, restore, and verification procedures for the -PostgreSQL database used by `scholarr`. +# Database Runbook -## Objectives +## Backup -- Keep data loss bounded via scheduled backups. -- Provide repeatable restore procedures. -- Verify backups by running regular restore drills. +Use `scripts/db/backup_full.sh` to create logical backups from the running `db` compose service. -Recommended starting targets: - -- `RPO`: 24 hours (maximum acceptable data loss). -- `RTO`: 60 minutes (maximum acceptable restore time). - -## Backup Strategy - -Use logical backups from the running `db` compose service. - -- Custom-format backup (recommended for restore flexibility): +### Custom-Format Backup (Default) ```bash scripts/db/backup_full.sh ``` -- Plain SQL backup: +Creates a `.dump` file in `./backups/` (e.g., `scholarr_20260227T120000Z.dump`). + +### Plain SQL Backup ```bash scripts/db/backup_full.sh --plain ``` -Optional environment variables: +Creates a `.sql` file. -- `BACKUP_DIR`: destination directory (default: `/backups`) -- `BACKUP_PREFIX`: backup filename prefix (default: `scholarr`) -- `USE_DEV_COMPOSE=1`: include `docker-compose.dev.yml` +### Options -## Restore Strategy +| Option | Description | +|--------|-------------| +| `--plain` | Write plain SQL instead of custom-format dump | -Restore from a `.dump` (custom format) or `.sql` file into the running `db` -compose service. +### Environment Variables -- Restore without schema wipe: +| Variable | Default | Description | +|----------|---------|-------------| +| `BACKUP_DIR` | `/backups` | Destination directory | +| `BACKUP_PREFIX` | `scholarr` | File prefix | +| `USE_DEV_COMPOSE` | `0` | Set to `1` to include `docker-compose.dev.yml` | + +## Restore + +Use `scripts/db/restore_dump.sh` to restore a backup into the running `db` service. + +### Restore a Custom-Format Dump ```bash -scripts/db/restore_dump.sh --file backups/scholarr_20260220T120000Z.dump +scripts/db/restore_dump.sh --file backups/scholarr_20260227T120000Z.dump ``` -- Restore with full `public` schema reset: +### Restore with Schema Wipe ```bash -scripts/db/restore_dump.sh --file backups/scholarr_20260220T120000Z.dump --wipe-public +scripts/db/restore_dump.sh --file backups/scholarr_20260227T120000Z.dump --wipe-public ``` -## Safety Checklist Before Restore +This drops and recreates the `public` schema before restoring. Use with caution. -1. Pause writes (stop app/scheduler or set maintenance mode). -2. Create a fresh pre-restore backup. -3. Validate target dump checksum/size. -4. Confirm operator, scope, and rollback plan. +### Options -## Post-Restore Verification +| Option | Description | +|--------|-------------| +| `--file ` | Required. Path to `.dump` or `.sql` backup file | +| `--wipe-public` | Drop and recreate `public` schema before restore | -1. Run migrations head check. -2. Run health endpoint checks. -3. Verify table row counts for core tables: - - `users` - - `scholar_profiles` - - `publications` - - `scholar_publications` - - `crawl_runs` -4. Run integrity report and require zero failures: +## Integrity Checks + +Use `scripts/db/check_integrity.py` to run database integrity checks. + +### Run Inside Container ```bash -python3 scripts/db/check_integrity.py +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + python scripts/db/check_integrity.py ``` -5. Run API smoke checks for scholars/publications/runs pages. - -## Data Cleanup and Repair - -Use the audited repair job for scholar-publication relinking cleanup: +### With Strict Warnings ```bash -python3 scripts/db/repair_publication_links.py --user-id --requested-by "" --apply +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + python scripts/db/check_integrity.py --strict-warnings ``` -Dry-run first, then re-run with `--apply` once scope and summary counts match expectation. +Returns non-zero exit code if any warning is present. -If cleanup changes schema assumptions, follow `docs/operations/migration-checklist.md` before any migration rollout. +### Output Format -## Restore Drill Cadence +JSON report: -- Run at least one full restore drill per month. -- Record: - - backup artifact used, - - restore start/end timestamps, - - issues encountered, - - achieved `RTO`. +```json +{ + "status": "passed", + "failures": [], + "warnings": [] +} +``` + +Exit codes: +- `0` - All checks passed +- `1` - Check failure +- `2` - Warnings present (with `--strict-warnings`) + +### Via Admin API + +``` +GET /api/v1/admin/db/integrity +``` + +Returns the same integrity report through the API (admin auth required). + +## Publication Link Repair + +Use `scripts/db/repair_publication_links.py` to repair scholar-publication links with audit logging. + +### Dry Run (Default) + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + python scripts/db/repair_publication_links.py --user-id 1 +``` + +### Apply Changes + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \ + python scripts/db/repair_publication_links.py --user-id 1 --apply \ + --requested-by "admin@example.com" +``` + +### Options + +| Option | Description | +|--------|-------------| +| `--user-id ` | Required. Target user ID | +| `--scholar-profile-id ` | Optional. Filter by scholar profile ID (repeatable) | +| `--apply` | Apply changes (default is dry-run) | +| `--gc-orphan-publications` | Delete publications with zero links after cleanup | +| `--requested-by ` | Operator identifier for audit logs | + +### Via Admin API + +``` +POST /api/v1/admin/db/repairs/publication-links +``` + +## Near-Duplicate Repair + +Detect and merge near-duplicate publications via the admin API: + +``` +POST /api/v1/admin/db/repairs/publication-near-duplicates +``` + +## PDF Queue Management + +### List Queue + +``` +GET /api/v1/admin/db/pdf-queue +``` + +### Requeue Single Item + +``` +POST /api/v1/admin/db/pdf-queue/{id}/requeue +``` + +### Bulk Requeue + +``` +POST /api/v1/admin/db/pdf-queue/requeue-all +``` + +## Migration Procedures + +Alembic migrations run automatically on startup when `MIGRATE_ON_START=1`. + +To run manually: + +```bash +docker compose exec app alembic upgrade head +``` + +To check current migration status: + +```bash +docker compose exec app alembic current +``` + +Recommended procedure for production: +1. Backup the database before upgrading. +2. Pull the new image. +3. Start the container; migrations run on startup. +4. Verify via `GET /healthz` and check logs for migration output. diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md new file mode 100644 index 0000000..48e06d4 --- /dev/null +++ b/docs/operations/deployment.md @@ -0,0 +1,117 @@ +--- +title: Deployment +sidebar_position: 2 +--- + +# Deployment + +## Production Docker Compose + +The default `docker-compose.yml` runs two services: + +| Service | Image | Description | +|---------|-------|-------------| +| `db` | `postgres:15-alpine` | PostgreSQL database with persistent volume | +| `app` | `justinzeus/scholarr:latest` | FastAPI application with embedded frontend | + +### Start + +```bash +docker compose up -d +``` + +### Stop + +```bash +docker compose down +``` + +### Update + +```bash +docker compose pull +docker compose up -d +``` + +### View Logs + +```bash +docker compose logs -f app +docker compose logs -f db +``` + +## Required Environment Variables + +These must be set in `.env` or the shell environment: + +```bash +POSTGRES_PASSWORD= +SESSION_SECRET_KEY= +``` + +## Volumes + +| Volume | Mount | Description | +|--------|-------|-------------| +| `postgres_data` | `/var/lib/postgresql/data` | Database files | +| `scholar_uploads` | `/var/lib/scholarr/uploads` | Scholar profile images | + +## Health Checks + +### Database + +```yaml +healthcheck: + test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 20 +``` + +### Application + +```yaml +healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:8000/healthz >/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 12 +``` + +The app service depends on `db` with `condition: service_healthy`, so it waits for the database to be ready. + +## Database Readiness Wait + +The app has built-in database readiness polling: + +- `DB_WAIT_TIMEOUT_SECONDS` (default: 60) - Max wait time +- `DB_WAIT_INTERVAL_SECONDS` (default: 2) - Poll interval + +## Auto-Migration + +Set `MIGRATE_ON_START=1` (default) to run Alembic migrations automatically on startup. + +## Scaling Considerations + +- Run a single `app` instance to avoid scheduler conflicts (the scheduler is process-local). +- arXiv requests are globally serialized via a PostgreSQL advisory lock, so multiple instances safely share the rate limiter. +- Database pool defaults: 5 base connections + 10 overflow. Adjust `DATABASE_POOL_SIZE` and `DATABASE_POOL_MAX_OVERFLOW` for higher loads. + +## Admin Bootstrap + +For first-time setup without manual database access: + +```bash +BOOTSTRAP_ADMIN_ON_START=1 +BOOTSTRAP_ADMIN_EMAIL=admin@example.com +BOOTSTRAP_ADMIN_PASSWORD= +``` + +Set `BOOTSTRAP_ADMIN_FORCE_PASSWORD=1` to reset an existing admin password. + +## Security Hardening + +- Set `SESSION_COOKIE_SECURE=1` when serving over HTTPS. +- Enable HSTS: `SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED=1`. +- Review CSP policy in `SECURITY_CSP_POLICY` for your domain. +- Set `UNPAYWALL_EMAIL` and `ARXIV_MAILTO` for polite API pool access. diff --git a/docs/operations/migration-checklist.md b/docs/operations/migration-checklist.md deleted file mode 100644 index 106b9fc..0000000 --- a/docs/operations/migration-checklist.md +++ /dev/null @@ -1,48 +0,0 @@ -# Migration Checklist - -Use this checklist for every schema/data migration. - -## 1. Design Review - -- Define change type: `expand`, `backfill`, `contract`. -- Document expected lock behavior and index impact. -- Confirm backward compatibility with currently deployed app version. -- Define rollback strategy before implementation. - -## 2. Pre-Migration Controls - -- Capture a fresh backup before applying migration. -- Confirm migration head revision and working tree cleanliness. -- Prepare validation queries for new/changed tables and indexes. -- Identify high-risk tables (large row count, hot write paths). - -## 3. Implementation Standards - -- Keep migrations idempotent when feasible. -- Prefer additive steps first (`nullable`, new index, new table). -- For destructive changes, separate into later contract migration. -- Avoid large blocking rewrites in a single deployment step. - -## 4. Verification - -- Apply migration in staging against production-like snapshot. -- Verify: - - expected tables/columns/indexes, - - app startup and health endpoint, - - affected API flows, - - data consistency queries. - -## 5. Rollout and Recovery - -- Apply migration during planned window. -- Monitor logs/errors and DB metrics during rollout. -- If rollback needed: - - follow downgrade/recovery runbook, - - restore from backup if downgrade is unsafe, - - document incident timeline. - -## 6. Post-Migration Tasks - -- Update `README.md` / `.env.example` / ops docs. -- Add/update integration tests for new schema assumptions. -- Record migration notes in changelog. diff --git a/docs/operations/overview.md b/docs/operations/overview.md index 36cae41..7d2f997 100644 --- a/docs/operations/overview.md +++ b/docs/operations/overview.md @@ -1,7 +1,30 @@ -# Operations Documentation +--- +title: Operations Overview +sidebar_position: 1 +--- -Use this section for production operations and incident/runbook workflows. +# Operations Overview -- [Scrape Safety Runbook](./scrape-safety-runbook.md) -- [Database Runbook](./database-runbook.md) -- [Migration Rollout Checklist](./migration-checklist.md) +This section covers production deployment, database administration, and scrape safety operations. + +## Quick Links + +| Guide | Description | +|-------|-------------| +| [Deployment](deployment.md) | Production Docker setup, scaling, health checks | +| [Database Runbook](database-runbook.md) | Backup, restore, integrity checks, repair procedures | +| [Scrape Safety Runbook](scrape-safety-runbook.md) | Rate limiting, cooldowns, CAPTCHA handling | +| [arXiv Runbook](arxiv-runbook.md) | arXiv rate limits, cache tuning, query patterns | + +## Health Check + +The app exposes `GET /healthz` for container orchestration: + +```bash +curl -fsS http://localhost:8000/healthz +``` + +Docker Compose healthcheck config: +- Interval: 10s +- Timeout: 5s +- Retries: 12 diff --git a/docs/operations/scrape-safety-runbook.md b/docs/operations/scrape-safety-runbook.md index db01064..71f6769 100644 --- a/docs/operations/scrape-safety-runbook.md +++ b/docs/operations/scrape-safety-runbook.md @@ -1,74 +1,76 @@ +--- +title: Scrape Safety Runbook +sidebar_position: 4 +--- + # Scrape Safety Runbook -This runbook covers operational handling for scrape safety cooldowns, threshold alerts, and blocked-IP events. +Operational handling for scrape safety cooldowns, threshold alerts, and blocked-IP events. ## Key Signals Use structured log `event` fields (recommended with `LOG_FORMAT=json`): -- `ingestion.safety_policy_blocked_run_start` -- `ingestion.safety_cooldown_entered` -- `ingestion.safety_cooldown_cleared` -- `ingestion.alert_blocked_failure_threshold_exceeded` -- `ingestion.alert_network_failure_threshold_exceeded` -- `ingestion.alert_retry_scheduled_threshold_exceeded` -- `api.runs.manual_blocked_policy` -- `api.runs.manual_blocked_safety` -- `scheduler.run_skipped_safety_cooldown` -- `scheduler.run_skipped_safety_cooldown_precheck` -- `scheduler.queue_item_deferred_safety_cooldown` +| Event | Description | +|-------|-------------| +| `ingestion.safety_policy_blocked_run_start` | Run blocked by safety policy | +| `ingestion.safety_cooldown_entered` | Cooldown activated | +| `ingestion.safety_cooldown_cleared` | Cooldown expired | +| `ingestion.alert_blocked_failure_threshold_exceeded` | Blocked failure threshold tripped | +| `ingestion.alert_network_failure_threshold_exceeded` | Network failure threshold tripped | +| `ingestion.alert_retry_scheduled_threshold_exceeded` | Retry threshold tripped | +| `api.runs.manual_blocked_policy` | Manual run blocked by policy | +| `api.runs.manual_blocked_safety` | Manual run blocked by safety cooldown | +| `scheduler.run_skipped_safety_cooldown` | Scheduled run skipped due to cooldown | +| `scheduler.run_skipped_safety_cooldown_precheck` | Scheduler precheck blocked | +| `scheduler.queue_item_deferred_safety_cooldown` | Queue item deferred due to cooldown | -Each event includes metric-style fields (`metric_name`, `metric_value`) for straightforward log-based alert rules. +Each event includes metric-style fields (`metric_name`, `metric_value`) for log-based alert rules. ## Recommended Alert Rules -- Cooldown enters: - - Trigger on `event=ingestion.safety_cooldown_entered`. -- Repeated start blocks: - - Trigger on high rate of `event=api.runs.manual_blocked_safety`. -- Threshold trips: - - Trigger on any of: - - `ingestion.alert_blocked_failure_threshold_exceeded` - - `ingestion.alert_network_failure_threshold_exceeded` - - `ingestion.alert_retry_scheduled_threshold_exceeded` -- Scheduler pressure: - - Trigger on sustained `scheduler.queue_item_deferred_safety_cooldown`. +- **Cooldown enters**: Trigger on `event=ingestion.safety_cooldown_entered` +- **Repeated start blocks**: High rate of `event=api.runs.manual_blocked_safety` +- **Threshold trips**: Any of the `*_threshold_exceeded` events +- **Scheduler pressure**: Sustained `scheduler.queue_item_deferred_safety_cooldown` ## If Your IP Appears Blocked -Symptoms: +### Symptoms -- cooldown reason `blocked_failure_threshold_exceeded` -- parse state `blocked_or_captcha` -- redirects toward Google account sign-in flows +- Cooldown reason: `blocked_failure_threshold_exceeded` +- Parse state: `blocked_or_captcha` +- Redirects toward Google account sign-in flows -Actions: +### Actions 1. Stop manual retries immediately. 2. Let cooldown expire; do not spam retriggers. 3. Increase `INGESTION_MIN_REQUEST_DELAY_SECONDS` and user request delay values. 4. Reduce concurrency pressure (keep one scheduler instance). -5. Keep name-search disabled/WIP for now if login-gated responses persist. +5. Keep name-search disabled if login-gated responses persist. 6. Resume with a small monitored run and verify blocked rate drops. -Avoid: +### Avoid -- aggressive rapid retries -- rotating through risky scraping patterns that increase challenge rates -- bypass/captcha-solving workflows that may violate source platform rules +- Aggressive rapid retries +- Rotating through risky scraping patterns that increase challenge rates +- Bypass/CAPTCHA-solving workflows that may violate source platform rules ## Environment Controls Policy floors and safety controls: -- `INGESTION_MIN_REQUEST_DELAY_SECONDS` -- `INGESTION_MIN_RUN_INTERVAL_MINUTES` -- `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` -- `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` -- `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD` -- `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS` -- `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS` -- `INGESTION_MANUAL_RUN_ALLOWED` -- `INGESTION_AUTOMATION_ALLOWED` +| Variable | Default | Description | +|----------|---------|-------------| +| `INGESTION_MIN_REQUEST_DELAY_SECONDS` | `2` | Floor delay between requests | +| `INGESTION_MIN_RUN_INTERVAL_MINUTES` | `15` | Minimum time between runs | +| `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` | `1` | Blocked failures before alert | +| `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` | `2` | Network failures before alert | +| `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD` | `3` | Retries before alert | +| `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS` | `1800` | Cooldown after blocked threshold (30 min) | +| `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS` | `900` | Cooldown after network threshold (15 min) | +| `INGESTION_MANUAL_RUN_ALLOWED` | `1` | Enable manual runs | +| `INGESTION_AUTOMATION_ALLOWED` | `1` | Enable automated runs | Apply stricter values first, then relax slowly only after sustained healthy runs. diff --git a/docs/reference/api-contract.md b/docs/reference/api-contract.md deleted file mode 100644 index 8b72186..0000000 --- a/docs/reference/api-contract.md +++ /dev/null @@ -1,31 +0,0 @@ -# API Contract - -## Envelope Invariant - -All API responses under `/api/v1` use one of these envelopes: - -- Success: `{"data": ..., "meta": {"request_id": "..."}}` -- Error: `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` - -`meta.request_id` must be present for success and error responses. - -Binary media assets are served outside `/api/v1` (for example, `GET /scholar-images/{scholar_profile_id}/upload`). - -## Publications Semantics - -- `GET /api/v1/publications` supports `mode=all|unread|latest`. -- `mode=new` is currently accepted as a compatibility alias for `latest`. -- Pagination controls: `page`, `page_size` (with backward-compatible `limit`/`offset` support). -- Pagination fields in response: `page`, `page_size`, `has_prev`, `has_next`, `total_count`. -- `unread` represents read-state (`is_read=false`). -- `latest` represents discovery-state (`first seen in the latest completed run`). - -Publication payloads expose: -- `pub_url` (canonical scholar detail URL) -- `doi` (normalized DOI) -- `pdf_url` (resolved OA PDF when available) - -## Scholar Portability - -- `GET /api/v1/scholars/export` exports tracked scholars and scholar-publication link state. -- `POST /api/v1/scholars/import` imports that payload while preserving global publication deduplication. diff --git a/docs/reference/api.md b/docs/reference/api.md new file mode 100644 index 0000000..668ab51 --- /dev/null +++ b/docs/reference/api.md @@ -0,0 +1,177 @@ +--- +title: API Contract +sidebar_position: 2 +--- + +# API Contract + +## Envelope Invariant + +All API responses under `/api/v1` use one of these envelopes: + +### Success + +```json +{ + "data": "...", + "meta": { + "request_id": "..." + } +} +``` + +### Error + +```json +{ + "error": { + "code": "...", + "message": "...", + "details": "..." + }, + "meta": { + "request_id": "..." + } +} +``` + +`meta.request_id` is present on both success and error responses. + +### Binary Assets + +Binary media assets are served outside `/api/v1`: + +``` +GET /scholar-images/{scholar_profile_id}/upload +``` + +## DTO Structure + +Scholarr uses strictly typed Pydantic V2 models serialized through OpenAPI v3 via FastAPI. + +- `PublicationListItem` exposes a `.display_identifier` property that resolves the highest-confidence identifier regardless of backend origin, rather than a hardcoded `.doi`. +- Frontend TypeScript types are compiled from the OpenAPI spec to ensure type safety across the stack. + +## Endpoints + +### Auth + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/auth/login` | Login (rate-limited sliding window) | +| `GET` | `/api/v1/auth/me` | Current session user | +| `GET` | `/api/v1/auth/csrf` | Bootstrap CSRF token | +| `POST` | `/api/v1/auth/change-password` | Change password | +| `POST` | `/api/v1/auth/logout` | Logout | + +### Scholars + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/scholars` | List tracked scholars | +| `POST` | `/api/v1/scholars` | Create scholar (auto-enqueue, metadata hydration) | +| `GET` | `/api/v1/scholars/search` | Search author candidates by name | +| `PATCH` | `/api/v1/scholars/{id}/toggle` | Toggle enabled status | +| `DELETE` | `/api/v1/scholars/{id}` | Delete scholar | +| `PUT` | `/api/v1/scholars/{id}/image/url` | Update image URL | +| `POST` | `/api/v1/scholars/{id}/image/upload` | Upload image | +| `DELETE` | `/api/v1/scholars/{id}/image` | Clear image customization | + +### Scholar Portability + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/scholars/export` | Export tracked scholars and publication link state | +| `POST` | `/api/v1/scholars/import` | Import scholars with global publication deduplication | + +The export payload includes scholar metadata, tracked publication data, and link state (read/unread, favorites). Import preserves global deduplication. + +### Publications + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/publications` | List publications (filtered, paginated) | +| `POST` | `/api/v1/publications/mark-all-read` | Mark all as read | +| `POST` | `/api/v1/publications/mark-read` | Mark selected as read | +| `POST` | `/api/v1/publications/{id}/retry-pdf` | Retry PDF resolution | +| `POST` | `/api/v1/publications/{id}/favorite` | Toggle favorite | + +#### Publication Modes + +`GET /api/v1/publications` supports a `mode` parameter: + +| Mode | Description | +|------|-------------| +| `all` | All publications | +| `unread` | Publications with `is_read=false` | +| `latest` | Publications first seen in the latest completed run | + +`mode=new` is accepted as a compatibility alias for `latest`. + +#### Pagination + +Query parameters: `page`, `page_size` (with backward-compatible `limit`/`offset` support). + +Response pagination fields: + +```json +{ + "page": 1, + "page_size": 20, + "has_prev": false, + "has_next": true, + "total_count": 142 +} +``` + +#### Publication Payload Fields + +| Field | Description | +|-------|-------------| +| `pub_url` | Canonical scholar detail URL | +| `doi` | Normalized DOI | +| `pdf_url` | Resolved open-access PDF URL (when available) | +| `display_identifier` | Highest-confidence identifier regardless of source | + +### Runs + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/runs` | List runs with safety state | +| `GET` | `/api/v1/runs/{run_id}` | Run detail with scholar results | +| `POST` | `/api/v1/runs/{run_id}/cancel` | Cancel active run | +| `POST` | `/api/v1/runs/manual` | Trigger manual run (idempotent, safety-checked) | +| `GET` | `/api/v1/runs/queue/items` | List queue items | +| `POST` | `/api/v1/runs/queue/{id}/retry` | Retry queue item | +| `POST` | `/api/v1/runs/queue/{id}/drop` | Drop queue item | +| `DELETE` | `/api/v1/runs/queue/{id}` | Clear queue item | +| `GET` | `/api/v1/runs/{run_id}/stream` | Stream run events (SSE) | + +### Settings + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/settings` | Get user settings (with cooldown expiry check) | +| `PUT` | `/api/v1/settings` | Update settings (interval, delay, nav, API keys) | + +### Admin - User Management + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/admin/users` | List all users | +| `POST` | `/api/v1/admin/users` | Create user | +| `PATCH` | `/api/v1/admin/users/{id}/active` | Set user active status | +| `POST` | `/api/v1/admin/users/{id}/reset-password` | Reset password | + +### Admin - Database Operations + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/admin/db/integrity` | Get integrity report | +| `GET` | `/api/v1/admin/db/repair-jobs` | List repair jobs | +| `GET` | `/api/v1/admin/db/pdf-queue` | List PDF queue | +| `POST` | `/api/v1/admin/db/pdf-queue/{id}/requeue` | Requeue single PDF | +| `POST` | `/api/v1/admin/db/pdf-queue/requeue-all` | Bulk requeue missing PDFs | +| `POST` | `/api/v1/admin/db/repairs/publication-links` | Trigger link repair | +| `POST` | `/api/v1/admin/db/repairs/publication-near-duplicates` | Trigger dedup repair | +| `POST` | `/api/v1/admin/db/drop-all-publications` | Drop all publications (destructive) | diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md new file mode 100644 index 0000000..23a7a11 --- /dev/null +++ b/docs/reference/changelog.md @@ -0,0 +1,12 @@ +--- +title: Changelog +sidebar_position: 4 +--- + +# Changelog + +This changelog is auto-generated by [python-semantic-release](https://python-semantic-release.readthedocs.io/). + +Release versions follow [Semantic Versioning](https://semver.org/). Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/). + + diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 0dafb9b..fc7e29a 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -1,68 +1,32 @@ -# Environment Reference and Audit +--- +title: Environment Variables +sidebar_position: 3 +--- -This document is the source-of-truth audit for what remains configurable versus internal defaults. +# Environment Variables Quick Reference -## Decision Summary +All environment variables are documented in detail in [Configuration](../user/configuration.md). -- Keep in `.env.example`: all runtime and compose controls currently exposed by `app/settings.py` plus compose/bootstrap extras. -- Move to internal defaults only: none at this time. -- Internal fallback behavior still exists in code to keep local/test startup resilient when values are omitted. +## Required Variables -## Configurable Variables (By Section) +| Variable | Description | +|----------|-------------| +| `POSTGRES_PASSWORD` | PostgreSQL password | +| `SESSION_SECRET_KEY` | Session cookie signing key (32+ characters) | -### Compose + Database +## Categories -`POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `DATABASE_URL`, `TEST_DATABASE_URL`, `SCHOLARR_IMAGE` +| Category | Key Variables | +|----------|--------------| +| Database | `POSTGRES_DB`, `POSTGRES_USER`, `DATABASE_URL`, `DATABASE_POOL_*` | +| Runtime | `APP_HOST`, `APP_PORT`, `MIGRATE_ON_START`, `FRONTEND_ENABLED` | +| Auth | `SESSION_SECRET_KEY`, `SESSION_COOKIE_SECURE`, `LOGIN_RATE_LIMIT_*` | +| Security | `SECURITY_HEADERS_ENABLED`, `SECURITY_CSP_*`, `SECURITY_STRICT_TRANSPORT_*` | +| Logging | `LOG_LEVEL`, `LOG_FORMAT`, `LOG_REQUESTS` | +| Scheduler | `SCHEDULER_ENABLED`, `SCHEDULER_TICK_SECONDS`, `SCHEDULER_*_BATCH_SIZE` | +| Ingestion | `INGESTION_*` (safety floors, cooldowns, retry policies) | +| Scholar | `SCHOLAR_IMAGE_*`, `SCHOLAR_NAME_SEARCH_*` | +| Enrichment | `UNPAYWALL_*`, `ARXIV_*`, `CROSSREF_*`, `OPENALEX_*`, `PDF_AUTO_RETRY_*` | +| Bootstrap | `BOOTSTRAP_ADMIN_*`, `DB_WAIT_*` | -### App Runtime + Networking - -`APP_NAME`, `APP_HOST`, `APP_PORT`, `APP_HOST_PORT`, `APP_RELOAD`, `MIGRATE_ON_START`, `FRONTEND_ENABLED`, `FRONTEND_DIST_DIR` - -### Database Pool - -`DATABASE_POOL_MODE`, `DATABASE_POOL_SIZE`, `DATABASE_POOL_MAX_OVERFLOW`, `DATABASE_POOL_TIMEOUT_SECONDS` - -### Frontend Dev Overrides - -`FRONTEND_HOST_PORT`, `CHOKIDAR_USEPOLLING`, `VITE_DEV_API_PROXY_TARGET` - -### Auth + Session - -`SESSION_SECRET_KEY`, `SESSION_COOKIE_SECURE`, `LOGIN_RATE_LIMIT_ATTEMPTS`, `LOGIN_RATE_LIMIT_WINDOW_SECONDS` - -### HTTP Security Headers + CSP - -`SECURITY_HEADERS_ENABLED`, `SECURITY_X_CONTENT_TYPE_OPTIONS`, `SECURITY_X_FRAME_OPTIONS`, `SECURITY_REFERRER_POLICY`, `SECURITY_PERMISSIONS_POLICY`, `SECURITY_CROSS_ORIGIN_OPENER_POLICY`, `SECURITY_CROSS_ORIGIN_RESOURCE_POLICY`, `SECURITY_CSP_ENABLED`, `SECURITY_CSP_POLICY`, `SECURITY_CSP_DOCS_POLICY`, `SECURITY_CSP_REPORT_ONLY`, `SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED`, `SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE`, `SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS`, `SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD` - -### Logging - -`LOG_LEVEL`, `LOG_FORMAT`, `LOG_REQUESTS`, `LOG_UVICORN_ACCESS`, `LOG_REQUEST_SKIP_PATHS`, `LOG_REDACT_FIELDS` - -### Scheduler + Ingestion Safety - -`SCHEDULER_ENABLED`, `SCHEDULER_TICK_SECONDS`, `SCHEDULER_QUEUE_BATCH_SIZE`, `INGESTION_AUTOMATION_ALLOWED`, `INGESTION_MANUAL_RUN_ALLOWED`, `INGESTION_MIN_RUN_INTERVAL_MINUTES`, `INGESTION_MIN_REQUEST_DELAY_SECONDS`, `INGESTION_NETWORK_ERROR_RETRIES`, `INGESTION_RETRY_BACKOFF_SECONDS`, `INGESTION_MAX_PAGES_PER_SCHOLAR`, `INGESTION_PAGE_SIZE`, `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD`, `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD`, `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD`, `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS`, `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS`, `INGESTION_CONTINUATION_QUEUE_ENABLED`, `INGESTION_CONTINUATION_BASE_DELAY_SECONDS`, `INGESTION_CONTINUATION_MAX_DELAY_SECONDS`, `INGESTION_CONTINUATION_MAX_ATTEMPTS` - -### Scholar Images + Name Search Safety - -`SCHOLAR_IMAGE_UPLOAD_DIR`, `SCHOLAR_IMAGE_UPLOAD_MAX_BYTES`, `SCHOLAR_NAME_SEARCH_ENABLED`, `SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS`, `SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS`, `SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES`, `SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS`, `SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS`, `SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD`, `SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS`, `SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD`, `SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD` - -### OA Enrichment + PDF Resolution - -`UNPAYWALL_ENABLED`, `UNPAYWALL_EMAIL`, `UNPAYWALL_TIMEOUT_SECONDS`, `UNPAYWALL_MIN_INTERVAL_SECONDS`, `UNPAYWALL_MAX_ITEMS_PER_REQUEST`, `UNPAYWALL_RETRY_COOLDOWN_SECONDS`, `UNPAYWALL_PDF_DISCOVERY_ENABLED`, `UNPAYWALL_PDF_DISCOVERY_MAX_CANDIDATES`, `UNPAYWALL_PDF_DISCOVERY_MAX_HTML_BYTES`, `PDF_AUTO_RETRY_INTERVAL_SECONDS`, `PDF_AUTO_RETRY_FIRST_INTERVAL_SECONDS`, `PDF_AUTO_RETRY_MAX_ATTEMPTS`, `CROSSREF_ENABLED`, `CROSSREF_MAX_ROWS`, `CROSSREF_TIMEOUT_SECONDS`, `CROSSREF_MIN_INTERVAL_SECONDS`, `CROSSREF_MAX_LOOKUPS_PER_REQUEST` - -### Startup Bootstrap + DB Wait - -`BOOTSTRAP_ADMIN_ON_START`, `BOOTSTRAP_ADMIN_EMAIL`, `BOOTSTRAP_ADMIN_PASSWORD`, `BOOTSTRAP_ADMIN_FORCE_PASSWORD`, `DB_WAIT_TIMEOUT_SECONDS`, `DB_WAIT_INTERVAL_SECONDS` - -## Drift Guard - -CI enforces env parity with: - -```bash -python3 scripts/check_env_contract.py -``` - -The check fails when: -- `app/settings.py` references a variable missing from `.env.example`. -- `.env.example` contains an unknown key (outside the approved compose/bootstrap extras). -- `.env.example` contains duplicate keys. +See [Configuration](../user/configuration.md) for the complete table with types, defaults, and descriptions. diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 240683d..c8249d9 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -1,6 +1,14 @@ -# Reference Documentation +--- +title: Reference +sidebar_position: 1 +--- -Use this section for canonical contracts and configuration references. +# Reference -- [API Contract](./api-contract.md) -- [Environment Reference](./environment.md) +Quick-reference documentation for the scholarr API, environment variables, and release changelog. + +| Document | Description | +|----------|-------------| +| [API Contract](api.md) | Envelope spec, endpoints, DTO structure, publication semantics | +| [Environment Variables](environment.md) | Quick-reference table linking to full configuration docs | +| [Changelog](changelog.md) | Auto-generated release history | diff --git a/docs/user/configuration.md b/docs/user/configuration.md new file mode 100644 index 0000000..d79a1b7 --- /dev/null +++ b/docs/user/configuration.md @@ -0,0 +1,178 @@ +--- +title: Configuration +sidebar_position: 3 +--- + +# Configuration + +All configuration is done through environment variables. Copy `.env.example` to `.env` and adjust values as needed. + +## Compose & Database + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `POSTGRES_DB` | string | `scholar` | PostgreSQL database name | +| `POSTGRES_USER` | string | `scholar` | PostgreSQL user | +| `POSTGRES_PASSWORD` | string | **required** | PostgreSQL password | +| `DATABASE_URL` | string | derived | SQLAlchemy async connection string | +| `TEST_DATABASE_URL` | string | derived | Override for test database. If empty, tests derive `_test` | +| `SCHOLARR_IMAGE` | string | `justinzeus/scholarr:latest` | Docker image for the app service | + +## App Runtime & Networking + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `APP_NAME` | string | `scholarr` | Application name used in logs and headers | +| `APP_HOST` | string | `0.0.0.0` | Bind address | +| `APP_PORT` | int | `8000` | Internal port | +| `APP_HOST_PORT` | int | `8000` | Host-mapped port | +| `APP_RELOAD` | bool | `0` | Enable uvicorn auto-reload (dev only) | +| `MIGRATE_ON_START` | bool | `1` | Run Alembic migrations on startup | +| `FRONTEND_ENABLED` | bool | `1` | Serve the built Vue frontend | +| `FRONTEND_DIST_DIR` | string | `/app/frontend/dist` | Path to compiled frontend assets | + +## Database Pool + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `DATABASE_POOL_MODE` | string | `auto` | Pool mode (`auto`, `fixed`, `null`) | +| `DATABASE_POOL_SIZE` | int | `5` | Base pool size | +| `DATABASE_POOL_MAX_OVERFLOW` | int | `10` | Maximum overflow connections | +| `DATABASE_POOL_TIMEOUT_SECONDS` | int | `30` | Connection acquisition timeout | + +## Frontend Dev Overrides + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `FRONTEND_HOST_PORT` | int | `5173` | Host port for Vite dev server | +| `CHOKIDAR_USEPOLLING` | bool | `1` | Enable polling for file watchers in containers | +| `VITE_DEV_API_PROXY_TARGET` | string | `http://app:8000` | Backend URL for Vite proxy | + +## Auth & Session + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `SESSION_SECRET_KEY` | string | **required** | Signing key for session cookies (32+ chars) | +| `SESSION_COOKIE_SECURE` | bool | `1` | Set `Secure` flag on session cookie (disable for local HTTP dev) | +| `LOGIN_RATE_LIMIT_ATTEMPTS` | int | `5` | Max login attempts per window | +| `LOGIN_RATE_LIMIT_WINDOW_SECONDS` | int | `60` | Sliding window for login rate limiting | + +## HTTP Security Headers & CSP + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `SECURITY_HEADERS_ENABLED` | bool | `1` | Enable security response headers | +| `SECURITY_X_CONTENT_TYPE_OPTIONS` | string | `nosniff` | X-Content-Type-Options header value | +| `SECURITY_X_FRAME_OPTIONS` | string | `DENY` | X-Frame-Options header value | +| `SECURITY_REFERRER_POLICY` | string | `strict-origin-when-cross-origin` | Referrer-Policy header value | +| `SECURITY_PERMISSIONS_POLICY` | string | *(restrictive)* | Permissions-Policy header value | +| `SECURITY_CROSS_ORIGIN_OPENER_POLICY` | string | `same-origin` | Cross-Origin-Opener-Policy header | +| `SECURITY_CROSS_ORIGIN_RESOURCE_POLICY` | string | `same-origin` | Cross-Origin-Resource-Policy header | +| `SECURITY_CSP_ENABLED` | bool | `1` | Enable Content-Security-Policy header | +| `SECURITY_CSP_POLICY` | string | *(restrictive)* | CSP for app routes | +| `SECURITY_CSP_DOCS_POLICY` | string | *(docs-specific)* | CSP for documentation routes | +| `SECURITY_CSP_REPORT_ONLY` | bool | `0` | Use report-only mode for CSP | +| `SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED` | bool | `0` | Enable HSTS header | +| `SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE` | int | `31536000` | HSTS max-age in seconds | +| `SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS` | bool | `1` | HSTS includeSubDomains directive | +| `SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD` | bool | `0` | HSTS preload directive | + +## Logging + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `LOG_LEVEL` | string | `INFO` | Root log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | +| `LOG_FORMAT` | string | `console` | Log format (`console` or `json`) | +| `LOG_REQUESTS` | bool | `1` | Log HTTP requests | +| `LOG_UVICORN_ACCESS` | bool | `0` | Enable uvicorn access log | +| `LOG_REQUEST_SKIP_PATHS` | string | `/healthz` | Comma-separated paths to exclude from request logging | +| `LOG_REDACT_FIELDS` | string | *(empty)* | Comma-separated field names to redact in logs | + +## Scheduler & Ingestion Safety + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `SCHEDULER_ENABLED` | bool | `1` | Enable the background scheduler | +| `SCHEDULER_TICK_SECONDS` | int | `60` | Scheduler poll interval | +| `SCHEDULER_QUEUE_BATCH_SIZE` | int | `10` | Max scholars processed per tick | +| `SCHEDULER_PDF_QUEUE_BATCH_SIZE` | int | `15` | Max PDF resolutions per tick | +| `INGESTION_AUTOMATION_ALLOWED` | bool | `1` | Allow automated (scheduled) runs | +| `INGESTION_MANUAL_RUN_ALLOWED` | bool | `1` | Allow manually triggered runs | +| `INGESTION_MIN_RUN_INTERVAL_MINUTES` | int | `15` | Minimum time between runs | +| `INGESTION_MIN_REQUEST_DELAY_SECONDS` | int | `2` | Floor delay between external requests | +| `INGESTION_NETWORK_ERROR_RETRIES` | int | `1` | Retries on network errors | +| `INGESTION_RETRY_BACKOFF_SECONDS` | float | `1.0` | Base backoff for network retries | +| `INGESTION_RATE_LIMIT_RETRIES` | int | `3` | Retries on 429 responses | +| `INGESTION_RATE_LIMIT_BACKOFF_SECONDS` | float | `30.0` | Backoff per 429 retry | +| `INGESTION_MAX_PAGES_PER_SCHOLAR` | int | `30` | Max paginated pages per scholar | +| `INGESTION_PAGE_SIZE` | int | `100` | Publications per page | +| `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` | int | `1` | Blocked failures before alert | +| `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` | int | `2` | Network failures before alert | +| `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD` | int | `3` | Scheduled retries before alert | +| `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS` | int | `1800` | Cooldown after blocked-failure threshold (30 min) | +| `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS` | int | `900` | Cooldown after network-failure threshold (15 min) | +| `INGESTION_CONTINUATION_QUEUE_ENABLED` | bool | `1` | Enable continuation queue for multi-page ingestion | +| `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` | int | `120` | Base delay for continuation queue items | +| `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` | int | `3600` | Max delay for continuation queue items | +| `INGESTION_CONTINUATION_MAX_ATTEMPTS` | int | `6` | Max continuation attempts per scholar | + +## Scholar Images & Name Search Safety + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `SCHOLAR_IMAGE_UPLOAD_DIR` | string | `/var/lib/scholarr/uploads` | Directory for uploaded scholar images | +| `SCHOLAR_IMAGE_UPLOAD_MAX_BYTES` | int | `2000000` | Max image upload size (2 MB) | +| `SCHOLAR_NAME_SEARCH_ENABLED` | bool | `1` | Enable name-based scholar search | +| `SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS` | int | `21600` | Cache TTL for successful searches (6 hours) | +| `SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS` | int | `300` | Cache TTL for blocked search results (5 min) | +| `SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES` | int | `512` | Max cache entries for name search | +| `SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS` | float | `8.0` | Min interval between name searches | +| `SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS` | float | `2.0` | Random jitter added to search interval | +| `SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD` | int | `1` | Blocked results before cooldown | +| `SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS` | int | `1800` | Cooldown after blocked name search (30 min) | +| `SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD` | int | `2` | Retries before alert | +| `SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD` | int | `3` | Cooldown rejections before alert | + +## OA Enrichment & PDF Resolution + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `UNPAYWALL_ENABLED` | bool | `1` | Enable Unpaywall DOI lookups | +| `UNPAYWALL_EMAIL` | string | *(empty)* | Polite pool email for Unpaywall API | +| `UNPAYWALL_TIMEOUT_SECONDS` | float | `4.0` | Request timeout | +| `UNPAYWALL_MIN_INTERVAL_SECONDS` | float | `0.6` | Min interval between Unpaywall requests | +| `UNPAYWALL_MAX_ITEMS_PER_REQUEST` | int | `20` | Max items per batch | +| `UNPAYWALL_RETRY_COOLDOWN_SECONDS` | int | `1800` | Cooldown after repeated failures | +| `UNPAYWALL_PDF_DISCOVERY_ENABLED` | bool | `1` | Enable HTML-based PDF link discovery | +| `UNPAYWALL_PDF_DISCOVERY_MAX_CANDIDATES` | int | `5` | Max candidate URLs to probe | +| `UNPAYWALL_PDF_DISCOVERY_MAX_HTML_BYTES` | int | `500000` | Max HTML response size to parse | +| `ARXIV_ENABLED` | bool | `1` | Enable arXiv API lookups | +| `ARXIV_TIMEOUT_SECONDS` | float | `3.0` | Request timeout | +| `ARXIV_MIN_INTERVAL_SECONDS` | float | `4.0` | Min interval between arXiv requests | +| `ARXIV_RATE_LIMIT_COOLDOWN_SECONDS` | float | `60.0` | Cooldown after arXiv 429 | +| `ARXIV_DEFAULT_MAX_RESULTS` | int | `3` | Default max results per query | +| `ARXIV_CACHE_TTL_SECONDS` | int | `900` | Query cache TTL (15 min) | +| `ARXIV_CACHE_MAX_ENTRIES` | int | `512` | Max cached queries | +| `ARXIV_MAILTO` | string | *(empty)* | Contact email for arXiv API headers | +| `PDF_AUTO_RETRY_INTERVAL_SECONDS` | int | `86400` | Auto-retry interval for failed PDFs (24 hours) | +| `PDF_AUTO_RETRY_FIRST_INTERVAL_SECONDS` | int | `3600` | First retry interval (1 hour) | +| `PDF_AUTO_RETRY_MAX_ATTEMPTS` | int | `3` | Max auto-retry attempts | +| `CROSSREF_ENABLED` | bool | `1` | Enable Crossref lookups | +| `CROSSREF_MAX_ROWS` | int | `10` | Max rows per Crossref query | +| `CROSSREF_TIMEOUT_SECONDS` | float | `8.0` | Request timeout | +| `CROSSREF_MIN_INTERVAL_SECONDS` | float | `0.6` | Min interval between Crossref requests | +| `CROSSREF_MAX_LOOKUPS_PER_REQUEST` | int | `8` | Max lookups per ingestion request | +| `OPENALEX_API_KEY` | string | *(empty)* | OpenAlex API key (optional) | +| `CROSSREF_API_TOKEN` | string | *(empty)* | Crossref Plus API token (optional) | +| `CROSSREF_API_MAILTO` | string | *(empty)* | Crossref polite pool email | + +## Startup Bootstrap & DB Wait + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `BOOTSTRAP_ADMIN_ON_START` | bool | `0` | Create admin user on startup | +| `BOOTSTRAP_ADMIN_EMAIL` | string | *(empty)* | Admin email address | +| `BOOTSTRAP_ADMIN_PASSWORD` | string | *(empty)* | Admin password | +| `BOOTSTRAP_ADMIN_FORCE_PASSWORD` | bool | `0` | Overwrite existing admin password | +| `DB_WAIT_TIMEOUT_SECONDS` | int | `60` | Max seconds to wait for database readiness | +| `DB_WAIT_INTERVAL_SECONDS` | int | `2` | Poll interval while waiting for database | diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md index bc15032..1090f86 100644 --- a/docs/user/getting-started.md +++ b/docs/user/getting-started.md @@ -1,41 +1,86 @@ -# User Getting Started +--- +title: Getting Started +sidebar_position: 2 +--- -## Required Setup +# Getting Started -1. Copy `.env.example` to `.env`. -2. Set `POSTGRES_PASSWORD`. -3. Set `SESSION_SECRET_KEY`. +## Prerequisites -## Deploy Method A: Prebuilt Image +- Docker and Docker Compose v2+ +- A machine with at least 512 MB RAM + +## Installation + +1. Clone the repository: + +```bash +git clone https://github.com/JustinZeus/scholarr.git +cd scholarr +``` + +2. Copy the example environment file: + +```bash +cp .env.example .env +``` + +3. Edit `.env` and set the required secrets: + +```bash +# Required: database password +POSTGRES_PASSWORD=your-secure-password + +# Required: session signing key (32+ random characters) +SESSION_SECRET_KEY=your-random-secret-key +``` + +4. Start the stack: + +```bash +docker compose up -d +``` + +5. Verify the service is healthy: + +```bash +docker compose ps +curl http://localhost:8000/healthz +``` + +The app is now available at `http://localhost:8000`. + +## First Run + +### Bootstrap an Admin User + +Set these environment variables before first start (or add them to `.env`): + +```bash +BOOTSTRAP_ADMIN_ON_START=1 +BOOTSTRAP_ADMIN_EMAIL=admin@example.com +BOOTSTRAP_ADMIN_PASSWORD=your-admin-password +``` + +Restart the app container. The admin account is created on startup. + +### Add Your First Scholar + +1. Log in at `http://localhost:8000`. +2. Navigate to the Scholars page. +3. Click **Add Scholar**. +4. Enter a Google Scholar profile URL (e.g., `https://scholar.google.com/citations?user=XXXXXXXXXX`) or a Scholar ID. +5. The scheduler will begin fetching publications on the next tick (default: 60 seconds). + +### Manual Run + +To trigger an immediate ingestion run, use the **Manual Run** button on the Runs page. This respects the minimum run interval (`INGESTION_MIN_RUN_INTERVAL_MINUTES`, default 15 minutes). + +## Updating ```bash docker compose pull docker compose up -d ``` -Open: -- App/API: `http://localhost:8000` -- Health endpoint: `http://localhost:8000/healthz` - -Upgrade: - -```bash -docker compose pull -docker compose up -d -``` - -## Deploy Method B: Local Source + Dev Frontend - -```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build -``` - -Open: -- API: `http://localhost:8000` -- Frontend dev server: `http://localhost:5173` - -Stop: - -```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml down -``` +Database migrations run automatically on startup when `MIGRATE_ON_START=1` (the default). diff --git a/docs/user/overview.md b/docs/user/overview.md index 29ee003..50af970 100644 --- a/docs/user/overview.md +++ b/docs/user/overview.md @@ -1,5 +1,29 @@ -# User Documentation +--- +title: Overview +sidebar_position: 1 +--- -Use this section if you are deploying and using scholarr. +# What is Scholarr? -- [Getting Started](./getting-started.md) +Scholarr is a self-hosted service that tracks academic publications from Google Scholar. It runs as a Docker container with a PostgreSQL database and serves a Vue 3 frontend. + +## Key Concepts + +- **Scholar** - A tracked Google Scholar profile. Scholars are user-scoped: each user manages their own list. +- **Publication** - A globally deduplicated academic work. Publications are shared across all users to avoid duplicate storage. +- **Scholar-Publication Link** - Connects a scholar to a publication for a specific user. Read/unread state, favorites, and visibility live on this link, not on the publication itself. +- **Run** - A single ingestion cycle that fetches new publications for one or more scholars. +- **Identifier** - A DOI, arXiv ID, PMID, or other external key attached to a publication. Multiple identifiers can exist per publication. + +## How It Works + +1. You add a Google Scholar profile (by ID, URL, or name search). +2. The scheduler periodically scrapes the profile for new publications. +3. Each publication is fingerprinted and deduplicated against the global store. +4. External APIs (arXiv, Crossref, OpenAlex) are queried for identifiers. +5. Unpaywall and arXiv resolve open-access PDF URLs when a DOI is available. +6. The dashboard shows new, unread, and all publications with filtering and search. + +## Safety Model + +Scholarr enforces rate limits and cooldowns to prevent IP bans from upstream sources. These are not configurable to zero; they are safety floors. See [Scrape Safety Runbook](../operations/scrape-safety-runbook.md) for details. diff --git a/docs/website/docusaurus.config.js b/docs/website/docusaurus.config.js index f32aeaa..6156ad9 100644 --- a/docs/website/docusaurus.config.js +++ b/docs/website/docusaurus.config.js @@ -2,8 +2,8 @@ /** @type {import('@docusaurus/types').Config} */ const config = { - title: "scholarr", - tagline: "Self-hosted scholar tracking docs", + title: "Scholarr", + tagline: "Self-hosted academic publication tracker", favicon: "img/favicon.ico", url: "https://justinzeus.github.io", @@ -23,59 +23,52 @@ const config = { presets: [ [ "classic", - { + /** @type {import('@docusaurus/preset-classic').Options} */ + ({ docs: { path: "..", - exclude: ["website/**", "README.md"], routeBasePath: "/", sidebarPath: require.resolve("./sidebars.js"), - editUrl: "https://github.com/JustinZeus/scholarr/tree/main/", + exclude: ["website/**", "README.md"], }, blog: false, theme: { customCss: require.resolve("./src/css/custom.css"), }, - }, + }), ], ], - themeConfig: { - navbar: { - title: "scholarr", - items: [ - { - type: "docSidebar", - sidebarId: "docsSidebar", - position: "left", - label: "Docs", - }, - { - href: "https://github.com/JustinZeus/scholarr", - label: "GitHub", - position: "right", - }, - ], - }, - footer: { - style: "dark", - links: [ - { - title: "Docs", - items: [{ label: "Index", to: "/" }], - }, - { - title: "Community", - items: [ - { - label: "GitHub", - href: "https://github.com/JustinZeus/scholarr", - }, - ], - }, - ], - copyright: `Copyright © ${new Date().getFullYear()} scholarr.`, - }, - }, + themeConfig: + /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ + ({ + navbar: { + title: "Scholarr", + items: [ + { to: "/user/overview", label: "User Guide", position: "left" }, + { + to: "/developer/overview", + label: "Developer", + position: "left", + }, + { + to: "/operations/overview", + label: "Operations", + position: "left", + }, + { to: "/reference/overview", label: "Reference", position: "left" }, + { + href: "https://github.com/JustinZeus/scholarr", + label: "GitHub", + position: "right", + }, + ], + }, + footer: { + style: "dark", + copyright: `Copyright \u00a9 ${new Date().getFullYear()} Scholarr. Built with Docusaurus.`, + }, + }), }; module.exports = config; diff --git a/docs/website/package.json b/docs/website/package.json index 383161f..85208b0 100644 --- a/docs/website/package.json +++ b/docs/website/package.json @@ -1,18 +1,27 @@ { "name": "scholarr-docs", - "version": "0.0.1", + "version": "0.1.0", "private": true, "scripts": { + "docusaurus": "docusaurus", "start": "docusaurus start", "build": "docusaurus build", + "swizzle": "docusaurus swizzle", + "deploy": "docusaurus deploy", + "clear": "docusaurus clear", "serve": "docusaurus serve" }, "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/preset-classic": "3.9.2", - "clsx": "^2.1.1", - "prism-react-renderer": "^2.3.1", + "@docusaurus/core": "^3.0.0", + "@docusaurus/preset-classic": "^3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" + }, + "devDependencies": { + "@docusaurus/module-type-aliases": "^3.0.0" + }, + "browserslist": { + "production": [">0.5%", "not dead", "not op_mini all"], + "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"] } } diff --git a/docs/website/sidebars.js b/docs/website/sidebars.js index 3189871..2b97349 100644 --- a/docs/website/sidebars.js +++ b/docs/website/sidebars.js @@ -1,38 +1,49 @@ /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ const sidebars = { - docsSidebar: [ + docs: [ "index", { type: "category", - label: "Users", - items: ["user/overview", "user/getting-started"], + label: "User Guide", + items: [ + "user/overview", + "user/getting-started", + "user/configuration", + ], + }, + { + type: "category", + label: "Developer", + items: [ + "developer/overview", + "developer/architecture", + "developer/local-development", + "developer/contributing", + "developer/ingestion", + "developer/frontend-theme-inventory", + "developer/testing", + ], }, { type: "category", label: "Operations", items: [ "operations/overview", - "operations/scrape-safety-runbook", + "operations/deployment", "operations/database-runbook", - "operations/migration-checklist", - ], - }, - { - type: "category", - label: "Developers", - items: [ - "developer/overview", - "developer/documentation-standards", - "developer/local-development", - "developer/architecture", - "developer/contributing", - "developer/frontend-theme-inventory", + "operations/scrape-safety-runbook", + "operations/arxiv-runbook", ], }, { type: "category", label: "Reference", - items: ["reference/overview", "reference/api-contract", "reference/environment"], + items: [ + "reference/overview", + "reference/api", + "reference/environment", + "reference/changelog", + ], }, ], }; diff --git a/docs/website/src/css/custom.css b/docs/website/src/css/custom.css index 6f9b7ae..c875358 100644 --- a/docs/website/src/css/custom.css +++ b/docs/website/src/css/custom.css @@ -1,9 +1,22 @@ :root { - --ifm-color-primary: #2a6b4e; - --ifm-color-primary-dark: #255f46; - --ifm-color-primary-darker: #225942; - --ifm-color-primary-darkest: #1c4936; - --ifm-color-primary-light: #2f7756; - --ifm-color-primary-lighter: #327d5a; - --ifm-color-primary-lightest: #3b946b; + --ifm-color-primary: #2e8555; + --ifm-color-primary-dark: #29784c; + --ifm-color-primary-darker: #277148; + --ifm-color-primary-darkest: #205d3b; + --ifm-color-primary-light: #33925d; + --ifm-color-primary-lighter: #359962; + --ifm-color-primary-lightest: #3cad6e; + --ifm-code-font-size: 95%; + --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); +} + +[data-theme="dark"] { + --ifm-color-primary: #25c2a0; + --ifm-color-primary-dark: #21af90; + --ifm-color-primary-darker: #1fa588; + --ifm-color-primary-darkest: #1a8870; + --ifm-color-primary-light: #29d5b0; + --ifm-color-primary-lighter: #32d8b4; + --ifm-color-primary-lightest: #4fddbf; + --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); } diff --git a/docs/website/static/img/favicon.ico b/docs/website/static/img/favicon.ico deleted file mode 100644 index 783fa02..0000000 Binary files a/docs/website/static/img/favicon.ico and /dev/null differ diff --git a/frontend/docs/website/package-lock.json b/frontend/docs/website/package-lock.json deleted file mode 100644 index 4ff32e6..0000000 --- a/frontend/docs/website/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "website", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 81441d5..4e5d821 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,7 +15,9 @@ "devDependencies": { "@types/node": "^22.10.2", "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.6", "autoprefixer": "^10.4.21", + "happy-dom": "^20.7.0", "postcss": "^8.5.3", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", @@ -474,6 +476,24 @@ "node": ">=12" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -550,6 +570,24 @@ "node": ">= 8" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -917,6 +955,23 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", @@ -1227,6 +1282,27 @@ "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/alien-signals": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", @@ -1234,6 +1310,32 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1502,6 +1604,26 @@ "node": ">= 6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1512,6 +1634,32 @@ "node": ">= 6" } }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1580,6 +1728,42 @@ "dev": true, "license": "MIT" }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -1587,6 +1771,13 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1728,6 +1919,23 @@ "node": ">=8" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -1767,6 +1975,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1780,6 +2010,24 @@ "node": ">=10.13.0" } }, + "node_modules/happy-dom": { + "version": "20.7.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.7.0.tgz", + "integrity": "sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1803,6 +2051,13 @@ "he": "bin/he" } }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1842,6 +2097,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1865,6 +2130,29 @@ "node": ">=0.12.0" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -1875,6 +2163,38 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1902,6 +2222,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1951,6 +2278,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2002,6 +2339,22 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -2032,6 +2385,13 @@ "node": ">= 6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -2039,6 +2399,16 @@ "dev": true, "license": "MIT" }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2046,6 +2416,23 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -2286,6 +2673,13 @@ "dev": true, "license": "MIT" }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2431,6 +2825,42 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2438,6 +2868,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2461,6 +2904,110 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2906,6 +3453,13 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-demi": { "version": "0.14.10", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", @@ -2964,6 +3518,32 @@ "typescript": ">=5.0.0" } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2980,6 +3560,126 @@ "engines": { "node": ">=8" } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 521cffc..912ec8a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,9 @@ "devDependencies": { "@types/node": "^22.10.2", "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.6", "autoprefixer": "^10.4.21", + "happy-dom": "^20.7.0", "postcss": "^8.5.3", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", diff --git a/frontend/src/components/layout/AppNav.vue b/frontend/src/components/layout/AppNav.vue index 788e5bf..1679059 100644 --- a/frontend/src/components/layout/AppNav.vue +++ b/frontend/src/components/layout/AppNav.vue @@ -39,6 +39,10 @@ const navRunStateStatus = computed(() => { if (runStatus.isSubmitting && !runStatus.isLikelyRunning) { return "starting"; } + const s = runStatus.latestRun?.status; + if (s === "resolving") { + return "resolving"; + } if (runStatus.isLikelyRunning) { return "running"; } diff --git a/frontend/src/components/patterns/RunStatusBadge.vue b/frontend/src/components/patterns/RunStatusBadge.vue index 575b5c7..da5accf 100644 --- a/frontend/src/components/patterns/RunStatusBadge.vue +++ b/frontend/src/components/patterns/RunStatusBadge.vue @@ -20,9 +20,15 @@ const toneClass = computed(() => { if (props.status === "running") { return "border-state-info-border bg-state-info-bg text-state-info-text"; } + if (props.status === "resolving") { + return "border-state-info-border bg-state-info-bg text-state-info-text"; + } if (props.status === "starting") { return "border-state-info-border bg-state-info-bg text-state-info-text"; } + if (props.status === "canceled") { + return "border-state-warning-border bg-state-warning-bg text-state-warning-text"; + } return "border-stroke-default bg-surface-card-muted text-ink-secondary"; }); diff --git a/frontend/src/components/ui/AppBrandMark.vue b/frontend/src/components/ui/AppBrandMark.vue index 0061ca4..f60e405 100644 --- a/frontend/src/components/ui/AppBrandMark.vue +++ b/frontend/src/components/ui/AppBrandMark.vue @@ -12,15 +12,15 @@ const props = withDefaults( const sizeClass = computed(() => { if (props.size === "sm") { - return "h-5 w-5"; + return "h-7 w-7"; } if (props.size === "lg") { - return "h-8 w-8"; + return "h-10 w-10"; } if (props.size === "xl") { - return "h-12 w-12"; + return "h-14 w-14"; } - return "h-6 w-6"; + return "h-7 w-7"; }); const logoMaskStyle: Record = { diff --git a/frontend/src/components/ui/AppHelpHint.vue b/frontend/src/components/ui/AppHelpHint.vue index 6736158..50d5b52 100644 --- a/frontend/src/components/ui/AppHelpHint.vue +++ b/frontend/src/components/ui/AppHelpHint.vue @@ -182,7 +182,7 @@ onBeforeUnmount(() => { :class=" hasTriggerSlot ? 'cursor-help rounded-sm' - : 'h-5 w-5 justify-center rounded-full border border-stroke-interactive/80 bg-surface-card-muted/80 text-[11px] font-semibold text-ink-secondary transition hover:bg-action-secondary-hover-bg' + : 'h-5 w-5 justify-center rounded-full border border-stroke-interactive/80 bg-surface-card-muted/80 text-xs font-semibold text-ink-secondary transition hover:bg-action-secondary-hover-bg' " :aria-label="text" @mouseenter="openTooltip" @@ -200,7 +200,7 @@ onBeforeUnmount(() => { role="tooltip" :style="tooltipStyle" :class="sideClass" - class="pointer-events-none fixed z-[90] w-64 max-w-[calc(100vw-2rem)] rounded-lg border border-stroke-default bg-surface-card/95 px-2.5 py-2 text-xs leading-relaxed text-ink-secondary shadow-lg" + class="pointer-events-none fixed z-50 w-auto max-w-xs rounded-lg border border-stroke-default bg-surface-card/95 px-2.5 py-2 text-xs leading-relaxed text-ink-secondary shadow-lg" > {{ text }} diff --git a/frontend/src/composables/useRequestState.test.ts b/frontend/src/composables/useRequestState.test.ts new file mode 100644 index 0000000..d8e8168 --- /dev/null +++ b/frontend/src/composables/useRequestState.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { ApiRequestError } from "@/lib/api/errors"; +import { useRequestState } from "@/composables/useRequestState"; + +describe("useRequestState", () => { + it("initializes with all values null", () => { + const { errorMessage, errorRequestId, successMessage } = useRequestState(); + expect(errorMessage.value).toBeNull(); + expect(errorRequestId.value).toBeNull(); + expect(successMessage.value).toBeNull(); + }); + + it("assignError extracts message and requestId from ApiRequestError", () => { + const { errorMessage, errorRequestId, assignError } = useRequestState(); + assignError( + new ApiRequestError({ status: 409, code: "conflict", message: "Conflict occurred", requestId: "req-42" }), + "fallback", + ); + expect(errorMessage.value).toBe("Conflict occurred"); + expect(errorRequestId.value).toBe("req-42"); + }); + + it("assignError uses Error.message for generic errors", () => { + const { errorMessage, errorRequestId, assignError } = useRequestState(); + assignError(new Error("something broke"), "fallback"); + expect(errorMessage.value).toBe("something broke"); + expect(errorRequestId.value).toBeNull(); + }); + + it("assignError uses fallback for non-Error values", () => { + const { errorMessage, assignError } = useRequestState(); + assignError("not an error object", "fallback text"); + expect(errorMessage.value).toBe("fallback text"); + }); + + it("setSuccess sets the success message", () => { + const { successMessage, setSuccess } = useRequestState(); + setSuccess("Operation completed"); + expect(successMessage.value).toBe("Operation completed"); + }); + + it("clearAlerts resets all messages", () => { + const { errorMessage, errorRequestId, successMessage, assignError, setSuccess, clearAlerts } = useRequestState(); + assignError(new ApiRequestError({ status: 500, code: "err", message: "fail", requestId: "r1" }), "fb"); + setSuccess("ok"); + clearAlerts(); + expect(errorMessage.value).toBeNull(); + expect(errorRequestId.value).toBeNull(); + expect(successMessage.value).toBeNull(); + }); +}); diff --git a/frontend/src/composables/useRequestState.ts b/frontend/src/composables/useRequestState.ts new file mode 100644 index 0000000..0e4c783 --- /dev/null +++ b/frontend/src/composables/useRequestState.ts @@ -0,0 +1,40 @@ +import { ref } from "vue"; +import { ApiRequestError } from "@/lib/api/errors"; + +export function useRequestState() { + const errorMessage = ref(null); + const errorRequestId = ref(null); + const successMessage = ref(null); + + function clearAlerts(): void { + errorMessage.value = null; + errorRequestId.value = null; + successMessage.value = null; + } + + function assignError(error: unknown, fallback: string): void { + if (error instanceof ApiRequestError) { + errorMessage.value = error.message; + errorRequestId.value = error.requestId; + return; + } + if (error instanceof Error && error.message) { + errorMessage.value = error.message; + return; + } + errorMessage.value = fallback; + } + + function setSuccess(message: string): void { + successMessage.value = message; + } + + return { + errorMessage, + errorRequestId, + successMessage, + clearAlerts, + assignError, + setSuccess, + }; +} diff --git a/frontend/src/features/admin_dbops/index.ts b/frontend/src/features/admin_dbops/index.ts index e3269e1..aa4a312 100644 --- a/frontend/src/features/admin_dbops/index.ts +++ b/frontend/src/features/admin_dbops/index.ts @@ -1,5 +1,13 @@ import { apiRequest } from "@/lib/api/client"; +export interface DisplayIdentifier { + kind: string; + value: string; + label: string; + url: string | null; + confidence_score: number; +} + export interface AdminDbIntegrityCheck { name: string; count: number; @@ -33,7 +41,7 @@ export interface AdminDbRepairJob { export interface AdminPdfQueueItem { publication_id: number; title: string; - doi: string | null; + display_identifier: DisplayIdentifier | null; pdf_url: string | null; status: string; attempt_count: number; @@ -87,6 +95,40 @@ export interface TriggerPublicationLinkRepairResult { summary: Record; } +export interface NearDuplicateClusterMember { + publication_id: number; + title: string; + year: number | null; + citation_count: number; +} + +export interface NearDuplicateCluster { + cluster_key: string; + winner_publication_id: number; + member_count: number; + similarity_score: number; + members: NearDuplicateClusterMember[]; +} + +export interface TriggerPublicationNearDuplicateRepairPayload { + dry_run?: boolean; + similarity_threshold?: number; + min_shared_tokens?: number; + max_year_delta?: number; + max_clusters?: number; + selected_cluster_keys?: string[]; + requested_by?: string; + confirmation_text?: string; +} + +export interface TriggerPublicationNearDuplicateRepairResult { + job_id: number; + status: string; + scope: Record; + summary: Record; + clusters: NearDuplicateCluster[]; +} + export async function getAdminDbIntegrityReport(): Promise { const response = await apiRequest("/admin/db/integrity", { method: "GET" }); return response.data; @@ -114,6 +156,19 @@ export async function triggerPublicationLinkRepair( return response.data; } +export async function triggerPublicationNearDuplicateRepair( + payload: TriggerPublicationNearDuplicateRepairPayload, +): Promise { + const response = await apiRequest( + "/admin/db/repairs/publication-near-duplicates", + { + method: "POST", + body: payload, + }, + ); + return response.data; +} + export async function listAdminPdfQueue( page = 1, pageSize = 100, @@ -151,3 +206,19 @@ export async function requeueAllAdminPdfLookups(limit = 1000): Promise { + const response = await apiRequest( + "/admin/db/drop-all-publications", + { + method: "POST", + body: { confirmation_text: confirmationText }, + }, + ); + return response.data; +} diff --git a/frontend/src/features/publications/components/PublicationTableRow.test.ts b/frontend/src/features/publications/components/PublicationTableRow.test.ts new file mode 100644 index 0000000..e3d8915 --- /dev/null +++ b/frontend/src/features/publications/components/PublicationTableRow.test.ts @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import PublicationTableRow from "./PublicationTableRow.vue"; +import type { PublicationItem } from "@/features/publications"; + +function buildItem(overrides: Partial = {}): PublicationItem { + return { + publication_id: 1, + scholar_profile_id: 10, + title: "Test Publication", + year: 2025, + citation_count: 42, + pub_url: "https://example.com/pub", + pdf_url: null, + pdf_status: "untracked", + is_read: false, + is_favorite: false, + is_new_in_latest_run: true, + scholar_label: "Dr. Test", + first_seen_at: "2025-01-15T00:00:00Z", + display_identifier: null, + ...overrides, + } as PublicationItem; +} + +const defaultProps = { + item: buildItem(), + itemKey: "pub-1", + selected: false, + favoriteUpdating: false, + retrying: false, + canRetry: false, +}; + +describe("PublicationTableRow", () => { + it("renders the publication title", () => { + const wrapper = mount(PublicationTableRow, { props: defaultProps }); + expect(wrapper.text()).toContain("Test Publication"); + }); + + it("renders scholar label", () => { + const wrapper = mount(PublicationTableRow, { props: defaultProps }); + expect(wrapper.text()).toContain("Dr. Test"); + }); + + it("renders year and citation count", () => { + const wrapper = mount(PublicationTableRow, { props: defaultProps }); + expect(wrapper.text()).toContain("2025"); + expect(wrapper.text()).toContain("42"); + }); + + it("shows New badge when is_new_in_latest_run is true", () => { + const wrapper = mount(PublicationTableRow, { props: defaultProps }); + expect(wrapper.text()).toContain("New"); + }); + + it("shows Seen badge when is_new_in_latest_run is false", () => { + const wrapper = mount(PublicationTableRow, { + props: { ...defaultProps, item: buildItem({ is_new_in_latest_run: false }) }, + }); + expect(wrapper.text()).toContain("Seen"); + }); + + it("shows Unread badge for unread items", () => { + const wrapper = mount(PublicationTableRow, { props: defaultProps }); + expect(wrapper.text()).toContain("Unread"); + }); + + it("shows Read badge for read items", () => { + const wrapper = mount(PublicationTableRow, { + props: { ...defaultProps, item: buildItem({ is_read: true }) }, + }); + expect(wrapper.text()).toContain("Read"); + }); + + it("disables checkbox when item is read", () => { + const wrapper = mount(PublicationTableRow, { + props: { ...defaultProps, item: buildItem({ is_read: true }) }, + }); + const checkbox = wrapper.find('input[type="checkbox"]'); + expect((checkbox.element as HTMLInputElement).disabled).toBe(true); + }); + + it("emits toggle-selection when checkbox is changed", async () => { + const wrapper = mount(PublicationTableRow, { props: defaultProps }); + await wrapper.find('input[type="checkbox"]').trigger("change"); + expect(wrapper.emitted("toggle-selection")).toHaveLength(1); + }); + + it("emits toggle-favorite when star button is clicked", async () => { + const wrapper = mount(PublicationTableRow, { props: defaultProps }); + await wrapper.find(".favorite-star-button").trigger("click"); + expect(wrapper.emitted("toggle-favorite")).toHaveLength(1); + }); + + it("shows filled star when item is favorite", () => { + const wrapper = mount(PublicationTableRow, { + props: { ...defaultProps, item: buildItem({ is_favorite: true }) }, + }); + expect(wrapper.find(".favorite-star-on").exists()).toBe(true); + expect(wrapper.text()).toContain("★"); + }); + + it("shows empty star when item is not favorite", () => { + const wrapper = mount(PublicationTableRow, { props: defaultProps }); + expect(wrapper.find(".favorite-star-off").exists()).toBe(true); + expect(wrapper.text()).toContain("☆"); + }); + + it("shows Available link when pdf_url is set", () => { + const wrapper = mount(PublicationTableRow, { + props: { ...defaultProps, item: buildItem({ pdf_url: "https://example.com/paper.pdf" }) }, + }); + expect(wrapper.text()).toContain("Available"); + }); + + it("shows retry button when canRetry is true and no pdf_url", () => { + const wrapper = mount(PublicationTableRow, { + props: { ...defaultProps, canRetry: true }, + }); + expect(wrapper.text()).toContain("Missing (Retry)"); + }); + + it("emits retry-pdf when retry button is clicked", async () => { + const wrapper = mount(PublicationTableRow, { + props: { ...defaultProps, canRetry: true }, + }); + await wrapper.find(".pdf-retry-button").trigger("click"); + expect(wrapper.emitted("retry-pdf")).toHaveLength(1); + }); + + it("shows Retrying... label when retrying", () => { + const wrapper = mount(PublicationTableRow, { + props: { ...defaultProps, canRetry: true, retrying: true }, + }); + expect(wrapper.text()).toContain("Retrying..."); + }); +}); diff --git a/frontend/src/features/publications/components/PublicationTableRow.vue b/frontend/src/features/publications/components/PublicationTableRow.vue new file mode 100644 index 0000000..01307ff --- /dev/null +++ b/frontend/src/features/publications/components/PublicationTableRow.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/frontend/src/features/publications/components/PublicationToolbar.test.ts b/frontend/src/features/publications/components/PublicationToolbar.test.ts new file mode 100644 index 0000000..2d1a39c --- /dev/null +++ b/frontend/src/features/publications/components/PublicationToolbar.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import PublicationToolbar from "./PublicationToolbar.vue"; + +const defaultProps = { + mode: "all" as const, + selectedScholarFilter: "", + searchQuery: "", + favoriteOnly: false, + actionBusy: false, + loading: false, + scholars: [], + startRunDisabled: false, + startRunDisabledReason: null, + startRunButtonLabel: "Check now", +}; + +describe("PublicationToolbar", () => { + it("renders mode, scholar filter, and search inputs", () => { + const wrapper = mount(PublicationToolbar, { props: defaultProps }); + expect(wrapper.text()).toContain("Status"); + expect(wrapper.text()).toContain("Scholar"); + expect(wrapper.text()).toContain("Search"); + }); + + it("renders the start run button with provided label", () => { + const wrapper = mount(PublicationToolbar, { props: defaultProps }); + expect(wrapper.text()).toContain("Check now"); + }); + + it("disables start run button when startRunDisabled is true", () => { + const wrapper = mount(PublicationToolbar, { + props: { ...defaultProps, startRunDisabled: true, startRunDisabledReason: "Cooldown active" }, + }); + const buttons = wrapper.findAll("button"); + const runButton = buttons.find((b) => b.text().includes("Check now")); + expect(runButton?.attributes("disabled")).toBeDefined(); + }); + + it("emits start-run when start button is clicked", async () => { + const wrapper = mount(PublicationToolbar, { props: defaultProps }); + const buttons = wrapper.findAll("button"); + const runButton = buttons.find((b) => b.text().includes("Check now")); + await runButton?.trigger("click"); + expect(wrapper.emitted("start-run")).toHaveLength(1); + }); + + it("emits favorite-only-changed when star filter is clicked", async () => { + const wrapper = mount(PublicationToolbar, { props: defaultProps }); + await wrapper.find(".favorite-filter-button").trigger("click"); + expect(wrapper.emitted("favorite-only-changed")).toHaveLength(1); + }); + + it("shows active star filter style when favoriteOnly is true", () => { + const wrapper = mount(PublicationToolbar, { props: { ...defaultProps, favoriteOnly: true } }); + expect(wrapper.find(".favorite-filter-on").exists()).toBe(true); + }); + + it("shows inactive star filter style when favoriteOnly is false", () => { + const wrapper = mount(PublicationToolbar, { props: defaultProps }); + expect(wrapper.find(".favorite-filter-off").exists()).toBe(true); + }); + + it("renders scholar options from props", () => { + const scholars = [ + { id: 1, scholar_id: "abc123", display_name: "Alice", profile_image_url: null, profile_image_source: "none" as const, is_enabled: true, baseline_completed: true, last_run_dt: null, last_run_status: null }, + { id: 2, scholar_id: "def456", display_name: "", profile_image_url: null, profile_image_source: "none" as const, is_enabled: true, baseline_completed: true, last_run_dt: null, last_run_status: null }, + ]; + const wrapper = mount(PublicationToolbar, { props: { ...defaultProps, scholars } }); + expect(wrapper.text()).toContain("Alice"); + expect(wrapper.text()).toContain("def456"); + }); + + it("shows clear button only when search query is non-empty", () => { + const empty = mount(PublicationToolbar, { props: defaultProps }); + expect(empty.text()).not.toContain("Clear"); + + const withQuery = mount(PublicationToolbar, { props: { ...defaultProps, searchQuery: "test" } }); + expect(withQuery.text()).toContain("Clear"); + }); + + it("emits reset-search when clear button is clicked", async () => { + const wrapper = mount(PublicationToolbar, { props: { ...defaultProps, searchQuery: "test" } }); + const clearButton = wrapper.findAll("button").find((b) => b.text().includes("Clear")); + await clearButton?.trigger("click"); + expect(wrapper.emitted("reset-search")).toHaveLength(1); + }); +}); diff --git a/frontend/src/features/publications/components/PublicationToolbar.vue b/frontend/src/features/publications/components/PublicationToolbar.vue new file mode 100644 index 0000000..4f34939 --- /dev/null +++ b/frontend/src/features/publications/components/PublicationToolbar.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/frontend/src/features/publications/composables/usePublicationData.test.ts b/frontend/src/features/publications/composables/usePublicationData.test.ts new file mode 100644 index 0000000..e06d9c7 --- /dev/null +++ b/frontend/src/features/publications/composables/usePublicationData.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; + +vi.mock("vue-router", () => ({ + useRoute: vi.fn(() => ({ query: {} })), + useRouter: vi.fn(() => ({ replace: vi.fn() })), +})); + +vi.mock("@/features/publications", async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { + ...original, + listPublications: vi.fn(), + }; +}); + +vi.mock("@/features/scholars", () => ({ + listScholars: vi.fn(), +})); + +import { listPublications } from "@/features/publications"; +import { listScholars } from "@/features/scholars"; +import { usePublicationData } from "./usePublicationData"; + +const mockedListPublications = vi.mocked(listPublications); +const mockedListScholars = vi.mocked(listScholars); + +describe("usePublicationData", () => { + beforeEach(() => { + setActivePinia(createPinia()); + mockedListPublications.mockReset(); + mockedListScholars.mockReset(); + }); + + it("initializes with default filter values", () => { + const data = usePublicationData(); + expect(data.mode.value).toBe("all"); + expect(data.favoriteOnly.value).toBe(false); + expect(data.searchQuery.value).toBe(""); + expect(data.selectedScholarFilter.value).toBe(""); + expect(data.loading.value).toBe(true); + }); + + it("loadScholarFilters calls listScholars", async () => { + mockedListScholars.mockResolvedValueOnce([ + { id: 1, scholar_id: "abc", display_name: "Test", profile_image_url: null, profile_image_source: "none" as const, is_enabled: true, baseline_completed: true, last_run_dt: null, last_run_status: null }, + ]); + const data = usePublicationData(); + await data.loadScholarFilters(); + expect(mockedListScholars).toHaveBeenCalled(); + expect(data.scholars.value).toHaveLength(1); + }); + + it("loadPublications calls listPublications with current filters", async () => { + mockedListPublications.mockResolvedValueOnce({ + publications: [], + mode: "all" as const, + favorite_only: false, + selected_scholar_profile_id: null, + unread_count: 0, + favorites_count: 0, + latest_count: 0, + new_count: 0, + total_count: 0, + page: 1, + page_size: 25, + snapshot: "", + has_next: false, + has_prev: false, + }); + const data = usePublicationData(); + await data.loadPublications(); + expect(mockedListPublications).toHaveBeenCalled(); + }); + + it("resetPageAndSnapshot resets page to 1", () => { + const data = usePublicationData(); + data.currentPage.value = 5; + data.resetPageAndSnapshot(); + expect(data.currentPage.value).toBe(1); + }); + + it("toggleSort reverses direction for same key", async () => { + mockedListPublications.mockResolvedValue({ + publications: [], + mode: "all" as const, + favorite_only: false, + selected_scholar_profile_id: null, + unread_count: 0, + favorites_count: 0, + latest_count: 0, + new_count: 0, + total_count: 0, + page: 1, + page_size: 25, + snapshot: "", + has_next: false, + has_prev: false, + }); + const data = usePublicationData(); + const initialDirection = data.sortDirection.value; + await data.toggleSort(data.sortKey.value); + expect(data.sortDirection.value).toBe(initialDirection === "asc" ? "desc" : "asc"); + }); + + it("sortMarker returns arrow indicators", () => { + const data = usePublicationData(); + expect(data.sortMarker(data.sortKey.value)).toMatch(/[↑↓]/); + expect(data.sortMarker("year" as never)).toBe("↕"); + }); + + it("computed pagination properties work correctly", () => { + const data = usePublicationData(); + expect(data.hasPrevPage.value).toBe(false); + expect(data.totalPages.value).toBe(1); + expect(data.totalCount.value).toBe(0); + }); +}); diff --git a/frontend/src/features/publications/composables/usePublicationData.ts b/frontend/src/features/publications/composables/usePublicationData.ts new file mode 100644 index 0000000..1ce8d11 --- /dev/null +++ b/frontend/src/features/publications/composables/usePublicationData.ts @@ -0,0 +1,342 @@ +import { computed, onScopeDispose, ref, watch } from "vue"; +import { useRoute, useRouter } from "vue-router"; +import { + listPublications, + type PublicationItem, + type PublicationMode, + type PublicationsResult, +} from "@/features/publications"; +import { listScholars, type ScholarProfile } from "@/features/scholars"; +import { ApiRequestError } from "@/lib/api/errors"; +import { useRunStatusStore } from "@/stores/run_status"; + +export type PublicationSortKey = + | "title" + | "scholar" + | "year" + | "citations" + | "first_seen" + | "pdf_status"; + +export function publicationKey(item: PublicationItem): string { + return `${item.scholar_profile_id}:${item.publication_id}`; +} + +export function usePublicationData() { + const loading = ref(true); + const mode = ref("all"); + const favoriteOnly = ref(false); + const selectedScholarFilter = ref(""); + const searchQuery = ref(""); + const sortKey = ref("first_seen"); + const sortDirection = ref<"asc" | "desc">("desc"); + const currentPage = ref(1); + const pageSize = ref("50"); + const publicationSnapshot = ref(null); + + const scholars = ref([]); + const listState = ref(null); + + const errorMessage = ref(null); + const errorRequestId = ref(null); + const successMessage = ref(null); + const route = useRoute(); + const router = useRouter(); + const textCollator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true }); + const runStatus = useRunStatusStore(); + + // --- Route sync --- + + function normalizeScholarFilterQuery(value: unknown): string { + if (Array.isArray(value)) return normalizeScholarFilterQuery(value[0]); + if (typeof value !== "string") return ""; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? String(parsed) : ""; + } + + function normalizeFavoriteOnlyQuery(value: unknown): boolean { + if (Array.isArray(value)) return normalizeFavoriteOnlyQuery(value[0]); + if (typeof value !== "string") return false; + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; + } + + function normalizePageQuery(value: unknown): number { + if (Array.isArray(value)) return normalizePageQuery(value[0]); + if (typeof value !== "string") return 1; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : 1; + } + + function syncFiltersFromRoute(): boolean { + let changed = false; + const nextScholar = normalizeScholarFilterQuery(route.query.scholar); + const nextFavoriteOnly = normalizeFavoriteOnlyQuery(route.query.favorite); + const nextPage = normalizePageQuery(route.query.page); + if (selectedScholarFilter.value !== nextScholar) { selectedScholarFilter.value = nextScholar; changed = true; } + if (favoriteOnly.value !== nextFavoriteOnly) { favoriteOnly.value = nextFavoriteOnly; changed = true; } + if (currentPage.value !== nextPage) { currentPage.value = nextPage; changed = true; } + return changed; + } + + async function syncFiltersToRoute(): Promise { + const nextScholar = selectedScholarFilter.value.trim(); + const currentScholar = normalizeScholarFilterQuery(route.query.scholar); + const currentFavoriteOnly = normalizeFavoriteOnlyQuery(route.query.favorite); + const currentPageQuery = normalizePageQuery(route.query.page); + if ( + nextScholar === currentScholar + && favoriteOnly.value === currentFavoriteOnly + && currentPage.value === currentPageQuery + ) { + return; + } + await router.replace({ + query: { + ...route.query, + scholar: nextScholar || undefined, + favorite: favoriteOnly.value ? "1" : undefined, + page: currentPage.value > 1 ? String(currentPage.value) : undefined, + }, + }); + } + + // --- Sorting helpers --- + + function publicationSortValue(item: PublicationItem, key: PublicationSortKey): number | string { + if (key === "title") return item.title; + if (key === "scholar") return item.scholar_label; + if (key === "year") return item.year ?? -1; + if (key === "citations") return item.citation_count; + if (key === "pdf_status") { + if (item.pdf_url || item.pdf_status === "resolved") return 4; + if (item.pdf_status === "running") return 3; + if (item.pdf_status === "queued") return 2; + if (item.pdf_status === "failed") return 0; + return 1; + } + const timestamp = Date.parse(item.first_seen_at); + return Number.isNaN(timestamp) ? 0 : timestamp; + } + + // --- Computed --- + + const selectedScholarName = computed(() => { + const selectedId = Number(selectedScholarFilter.value); + if (!Number.isInteger(selectedId) || selectedId <= 0) return "all scholars"; + const profile = scholars.value.find((item) => item.id === selectedId); + return profile ? (profile.display_name || profile.scholar_id) : "the selected scholar"; + }); + + const filteredPublications = computed(() => { + let stream = [...runStatus.livePublications]; + if (favoriteOnly.value) stream = stream.filter((p) => p.is_favorite); + if (mode.value === "unread") stream = stream.filter((p) => !p.is_read); + const selectedScholarId = Number(selectedScholarFilter.value); + if (Number.isInteger(selectedScholarId) && selectedScholarId > 0) { + stream = stream.filter((p) => p.scholar_profile_id === selectedScholarId); + } + const base = listState.value?.publications ?? []; + const merged = [...stream, ...base]; + const seenKeys = new Set(); + const deduped: typeof base = []; + for (const item of merged) { + const key = publicationKey(item); + if (!seenKeys.has(key)) { seenKeys.add(key); deduped.push(item); } + } + return deduped; + }); + + const sortedPublications = computed(() => { + const direction = sortDirection.value === "asc" ? 1 : -1; + return [...filteredPublications.value].sort((left, right) => { + const leftValue = publicationSortValue(left, sortKey.value); + const rightValue = publicationSortValue(right, sortKey.value); + let comparison = 0; + if (typeof leftValue === "string" && typeof rightValue === "string") { + comparison = textCollator.compare(leftValue, rightValue); + } else { + comparison = Number(leftValue) - Number(rightValue); + } + if (comparison !== 0) return comparison * direction; + return right.publication_id - left.publication_id; + }); + }); + + const visibleUnreadKeys = computed(() => { + const keys = new Set(); + for (const item of sortedPublications.value) { + if (!item.is_read) keys.add(publicationKey(item)); + } + return keys; + }); + + const pageSizeValue = computed(() => { + const parsed = Number(pageSize.value); + if (!Number.isInteger(parsed)) return 50; + return Math.max(10, Math.min(200, parsed)); + }); + const hasNextPage = computed(() => Boolean(listState.value?.has_next)); + const hasPrevPage = computed(() => Boolean(listState.value?.has_prev)); + const totalPages = computed(() => { + if (!listState.value) return 1; + return Math.max(1, Math.ceil(listState.value.total_count / Math.max(listState.value.page_size, 1))); + }); + const totalCount = computed(() => listState.value?.total_count ?? 0); + const visibleCount = computed(() => sortedPublications.value.length); + const visibleUnreadCount = computed(() => visibleUnreadKeys.value.size); + const visibleFavoriteCount = computed( + () => sortedPublications.value.filter((item) => item.is_favorite).length, + ); + + // --- Data loading --- + + async function loadScholarFilters(): Promise { + try { scholars.value = await listScholars(); } catch { scholars.value = []; } + } + + function selectedScholarId(): number | undefined { + const parsed = Number(selectedScholarFilter.value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; + } + + async function loadPublications(): Promise { + loading.value = true; + errorMessage.value = null; + errorRequestId.value = null; + try { + listState.value = await listPublications({ + mode: mode.value, + favoriteOnly: favoriteOnly.value, + scholarProfileId: selectedScholarId(), + search: searchQuery.value.trim() || undefined, + sortBy: sortKey.value, + sortDir: sortDirection.value, + page: currentPage.value, + pageSize: pageSizeValue.value, + snapshot: publicationSnapshot.value ?? undefined, + }); + publicationSnapshot.value = listState.value.snapshot; + currentPage.value = listState.value.page; + pageSize.value = String(listState.value.page_size); + } catch (error) { + listState.value = null; + if (error instanceof ApiRequestError) { + errorMessage.value = error.message; + errorRequestId.value = error.requestId; + } else { + errorMessage.value = "Unable to load publications."; + } + } finally { + loading.value = false; + } + } + + function resetPageAndSnapshot(): void { + currentPage.value = 1; + publicationSnapshot.value = null; + } + + async function toggleSort(nextKey: PublicationSortKey): Promise { + if (sortKey.value === nextKey) { + sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc"; + } else { + sortKey.value = nextKey; + sortDirection.value = nextKey === "first_seen" || nextKey === "pdf_status" ? "desc" : "asc"; + } + resetPageAndSnapshot(); + await loadPublications(); + } + + function sortMarker(key: PublicationSortKey): string { + if (sortKey.value !== key) return "↕"; + return sortDirection.value === "asc" ? "↑" : "↓"; + } + + function replacePublication(updated: PublicationItem): void { + if (!listState.value) return; + listState.value.publications = listState.value.publications.map((item) => + publicationKey(item) !== publicationKey(updated) ? item : updated, + ); + } + + // --- Search debounce watcher setup --- + + let searchDebounceTimer: ReturnType | null = null; + watch(searchQuery, () => { + if (searchDebounceTimer !== null) clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => { + resetPageAndSnapshot(); + void loadPublications(); + }, 300); + }); + + onScopeDispose(() => { + if (searchDebounceTimer !== null) clearTimeout(searchDebounceTimer); + }); + + // --- Run-triggered refresh watcher --- + + watch( + () => runStatus.latestRun, + async (nextRun, previousRun) => { + const nextRunId = nextRun && (nextRun.status === "running" || nextRun.status === "resolving") ? nextRun.id : null; + const previousRunId = previousRun && (previousRun.status === "running" || previousRun.status === "resolving") ? previousRun.id : null; + if (nextRunId === null || nextRunId === previousRunId) return; + resetPageAndSnapshot(); + await loadPublications(); + }, + ); + + // --- Route watcher --- + + watch( + () => [route.query.scholar, route.query.favorite, route.query.page], + async () => { + const previousScholar = selectedScholarFilter.value; + const previousFavorite = favoriteOnly.value; + const changed = syncFiltersFromRoute(); + if (!changed) return; + if (selectedScholarFilter.value !== previousScholar || favoriteOnly.value !== previousFavorite) { + publicationSnapshot.value = null; + } + await loadPublications(); + }, + ); + + return { + loading, + mode, + favoriteOnly, + selectedScholarFilter, + searchQuery, + sortKey, + sortDirection, + currentPage, + pageSize, + scholars, + listState, + errorMessage, + errorRequestId, + successMessage, + selectedScholarName, + sortedPublications, + visibleUnreadKeys, + pageSizeValue, + hasNextPage, + hasPrevPage, + totalPages, + totalCount, + visibleCount, + visibleUnreadCount, + visibleFavoriteCount, + loadScholarFilters, + loadPublications, + resetPageAndSnapshot, + toggleSort, + sortMarker, + replacePublication, + syncFiltersFromRoute, + syncFiltersToRoute, + }; +} diff --git a/frontend/src/features/publications/index.ts b/frontend/src/features/publications/index.ts index 2eddfbb..a689ba9 100644 --- a/frontend/src/features/publications/index.ts +++ b/frontend/src/features/publications/index.ts @@ -1,6 +1,21 @@ import { apiRequest } from "@/lib/api/client"; export type PublicationMode = "all" | "unread" | "latest"; +export type PublicationSortBy = + | "first_seen" + | "title" + | "year" + | "citations" + | "scholar" + | "pdf_status"; + +export interface DisplayIdentifier { + kind: string; + value: string; + label: string; + url: string | null; + confidence_score: number; +} export interface PublicationItem { publication_id: number; @@ -11,7 +26,7 @@ export interface PublicationItem { citation_count: number; venue_text: string | null; pub_url: string | null; - doi: string | null; + display_identifier: DisplayIdentifier | null; pdf_url: string | null; pdf_status: "untracked" | "queued" | "running" | "resolved" | "failed"; pdf_attempt_count: number; @@ -35,6 +50,7 @@ export interface PublicationsResult { total_count: number; page: number; page_size: number; + snapshot: string; has_next: boolean; has_prev: boolean; publications: PublicationItem[]; @@ -44,8 +60,12 @@ export interface PublicationsQuery { mode?: PublicationMode; favoriteOnly?: boolean; scholarProfileId?: number; + search?: string; + sortBy?: PublicationSortBy; + sortDir?: "asc" | "desc"; page?: number; pageSize?: number; + snapshot?: string; } export interface PublicationSelection { @@ -65,12 +85,24 @@ export async function listPublications(query: PublicationsQuery = {}): Promise

0) { + params.set("search", query.search.trim()); + } + if (query.sortBy) { + params.set("sort_by", query.sortBy); + } + if (query.sortDir) { + params.set("sort_dir", query.sortDir); + } const parsedPage = Number.isFinite(query.page) ? Math.max(1, Math.trunc(Number(query.page))) : 1; const parsedPageSize = Number.isFinite(query.pageSize) ? Math.max(1, Math.min(500, Math.trunc(Number(query.pageSize)))) : 100; params.set("page", String(parsedPage)); params.set("page_size", String(parsedPageSize)); + if (query.snapshot && query.snapshot.trim().length > 0) { + params.set("snapshot", query.snapshot.trim()); + } const suffix = params.toString(); const response = await apiRequest( diff --git a/frontend/src/features/runs/index.ts b/frontend/src/features/runs/index.ts index 97d49c6..735776d 100644 --- a/frontend/src/features/runs/index.ts +++ b/frontend/src/features/runs/index.ts @@ -145,6 +145,13 @@ export async function triggerManualRun(): Promise<{ return response.data; } +export async function cancelRun(runId: number): Promise { + const response = await apiRequest(`/runs/${runId}/cancel`, { + method: "POST", + }); + return response.data; +} + export async function listQueueItems(limit = 200): Promise { const response = await apiRequest(`/runs/queue/items?limit=${limit}`, { method: "GET", diff --git a/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts b/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts new file mode 100644 index 0000000..2d76eac --- /dev/null +++ b/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import ScholarBatchAdd from "./ScholarBatchAdd.vue"; + +const defaultProps = { saving: false, loading: false }; + +describe("ScholarBatchAdd", () => { + it("renders the heading and input", () => { + const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); + expect(wrapper.text()).toContain("Add Scholar Profiles"); + expect(wrapper.find("textarea").exists()).toBe(true); + }); + + it("shows parsed ID count as 0 for empty input", () => { + const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); + expect(wrapper.text()).toContain("Parsed IDs:"); + }); + + it("parses bare scholar IDs from textarea input", async () => { + const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); + await wrapper.find("textarea").setValue("A-UbBTPM15wL"); + expect(wrapper.text()).toContain("1"); + }); + + it("parses scholar IDs from Google Scholar URLs", async () => { + const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); + await wrapper.find("textarea").setValue( + "https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL", + ); + expect(wrapper.text()).toContain("1"); + }); + + it("deduplicates IDs from mixed input", async () => { + const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); + await wrapper.find("textarea").setValue( + "A-UbBTPM15wL\nhttps://scholar.google.com/citations?user=A-UbBTPM15wL", + ); + expect(wrapper.text()).toContain("1"); + }); + + it("parses multiple IDs separated by commas", async () => { + const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); + await wrapper.find("textarea").setValue("A-UbBTPM15wL, B-UbBTPM15wL"); + expect(wrapper.text()).toContain("2"); + }); + + it("emits add-scholars with parsed IDs on submit", async () => { + const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); + await wrapper.find("textarea").setValue("A-UbBTPM15wL"); + await wrapper.find("form").trigger("submit"); + const emitted = wrapper.emitted("add-scholars"); + expect(emitted).toHaveLength(1); + expect(emitted![0][0]).toEqual(["A-UbBTPM15wL"]); + }); + + it("clears textarea after successful submit", async () => { + const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); + const textarea = wrapper.find("textarea"); + await textarea.setValue("A-UbBTPM15wL"); + await wrapper.find("form").trigger("submit"); + expect((textarea.element as HTMLTextAreaElement).value).toBe(""); + }); + + it("does not emit when input contains no valid IDs", async () => { + const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); + await wrapper.find("textarea").setValue("not-a-valid-id"); + await wrapper.find("form").trigger("submit"); + expect(wrapper.emitted("add-scholars")).toBeUndefined(); + }); + + it("shows Adding... label when saving prop is true", () => { + const wrapper = mount(ScholarBatchAdd, { props: { ...defaultProps, saving: true } }); + expect(wrapper.text()).toContain("Adding..."); + }); +}); diff --git a/frontend/src/features/scholars/components/ScholarBatchAdd.vue b/frontend/src/features/scholars/components/ScholarBatchAdd.vue new file mode 100644 index 0000000..bfafbda --- /dev/null +++ b/frontend/src/features/scholars/components/ScholarBatchAdd.vue @@ -0,0 +1,89 @@ + + +