From ca565e25304e8b87b2cbd1f15208e8df1248ca75 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Sat, 21 Feb 2026 17:53:29 +0100 Subject: [PATCH 01/68] docs --- .github/workflows/ci.yml | 4 +- .github/workflows/docs-pages.yml | 7 ++- .gitignore | 4 +- README.md | 29 ++--------- docs/README.md | 35 ++++++------- .../architecture.md} | 0 docs/{ => developer}/contributing.md | 0 docs/developer/documentation-standards.md | 26 ++++++++++ .../frontend-theme-inventory.md} | 0 .../local-development.md} | 46 ++++++------------ docs/developer/overview.md | 9 ++++ docs/index.md | 33 ++++++++++--- .../database-runbook.md} | 2 +- .../migration-checklist.md} | 0 docs/operations/overview.md | 7 +++ .../scrape-safety-runbook.md} | 0 .../{api_contract.md => api-contract.md} | 0 docs/reference/overview.md | 6 +++ docs/user/getting-started.md | 41 ++++++++++++++++ docs/user/overview.md | 5 ++ .../website}/docusaurus.config.js | 13 ++--- {website => docs/website}/package.json | 0 docs/website/sidebars.js | 40 +++++++++++++++ {website => docs/website}/src/css/custom.css | 0 .../website}/static/img/favicon.ico | Bin website/sidebars.js | 6 --- 26 files changed, 209 insertions(+), 104 deletions(-) rename docs/{architecture/domain_boundaries.md => developer/architecture.md} (100%) rename docs/{ => developer}/contributing.md (100%) create mode 100644 docs/developer/documentation-standards.md rename docs/{frontend/theme_phase0_inventory.md => developer/frontend-theme-inventory.md} (100%) rename docs/{deploy/quickstart.md => developer/local-development.md} (60%) create mode 100644 docs/developer/overview.md rename docs/{ops/db_runbook.md => operations/database-runbook.md} (95%) rename docs/{ops/migration_checklist.md => operations/migration-checklist.md} (100%) create mode 100644 docs/operations/overview.md rename docs/{ops/scrape_safety_runbook.md => operations/scrape-safety-runbook.md} (100%) rename docs/reference/{api_contract.md => api-contract.md} (100%) create mode 100644 docs/reference/overview.md create mode 100644 docs/user/getting-started.md create mode 100644 docs/user/overview.md rename {website => docs/website}/docusaurus.config.js (81%) rename {website => docs/website}/package.json (100%) create mode 100644 docs/website/sidebars.js rename {website => docs/website}/src/css/custom.css (100%) rename {website => docs/website}/static/img/favicon.ico (100%) delete mode 100644 website/sidebars.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1459666..9a93c27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,10 +124,10 @@ jobs: node-version: "20" - name: Install docs dependencies - run: npm install --prefix website + run: npm install --prefix docs/website - name: Docs build - run: npm --prefix website run build + run: npm --prefix docs/website run build docker-publish: runs-on: ubuntu-latest diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 8bdb167..1e699e5 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -7,7 +7,6 @@ on: - main paths: - docs/** - - website/** - .github/workflows/docs-pages.yml permissions: @@ -33,15 +32,15 @@ jobs: node-version: "20" - name: Install docs dependencies - run: npm install --prefix website + run: npm install --prefix docs/website - name: Build docs site - run: npm --prefix website run build + run: npm --prefix docs/website run build - name: Upload pages artifact uses: actions/upload-pages-artifact@v3 with: - path: website/build + path: docs/website/build deploy: needs: build diff --git a/.gitignore b/.gitignore index 218252b..88c4ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,6 @@ planning/ frontend/node_modules/ frontend/dist/ frontend/.vite/ -website/node_modules/ -website/build/ +docs/website/node_modules/ +docs/website/build/ AGENTS.MD diff --git a/README.md b/README.md index b31ab8f..5d5f84f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Self-hosted scholar tracking with a single app image (API + frontend). -[![CI](https://img.shields.io/github/actions/workflow/status/justinzeus/scholarr/ci.yml?style=for-the-badge)](https://github.com/JustinZeus/scholar-scraper/actions/workflows/ci.yml) +[![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) [![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) @@ -35,32 +35,11 @@ Open: ## Documentation -Primary docs live under `docs/`. +Complete documentation is published at: -- Index: `docs/README.md` -- Deploy and dev workflow: `docs/deploy/quickstart.md` -- Environment reference: `docs/reference/environment.md` -- API contract and payload conventions: `docs/reference/api_contract.md` -- Architecture and domain boundaries: `docs/architecture/domain_boundaries.md` -- Scrape safety runbook: `docs/ops/scrape_safety_runbook.md` -- DB backup/restore/integrity runbook: `docs/ops/db_runbook.md` -- Migration rollout checklist: `docs/ops/migration_checklist.md` -- Frontend theme inventory: `docs/frontend/theme_phase0_inventory.md` -- Contributing policy: `docs/contributing.md` +- https://justinzeus.github.io/scholarr/ -## Docs Site (Docusaurus) - -Docusaurus source lives in `website/` and consumes markdown from `docs/`. - -Local build: - -```bash -cd website -npm install -npm run build -``` - -GitHub Pages deploy is automated via `.github/workflows/docs-pages.yml`. +Source markdown and docs tooling live under `docs/`. ## Quality Gates diff --git a/docs/README.md b/docs/README.md index 33a4a56..f6b8216 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,30 +1,23 @@ # Documentation Index -Use this directory for project documentation outside `README.md` and `agents.md`. +This directory contains both docs content and docs tooling. -## Deploy +## Audience-Based Structure -- `docs/deploy/quickstart.md`: deployment modes, local dev startup, and quality command reference. +- `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. -## Reference +## Docs Tooling -- `docs/reference/environment.md`: complete environment-variable catalog and contract/audit notes. -- `docs/reference/api_contract.md`: API envelope contract and endpoint semantics. +- `docs/website/`: Docusaurus app used to publish docs to GitHub Pages. -## Architecture +## Recommended Reading Paths -- `docs/architecture/domain_boundaries.md`: domain boundaries, user-scoped data rules, and frontend behavior notes. +- Users: `docs/user/getting-started.md` +- Operators: `docs/operations/overview.md` +- Developers: `docs/developer/overview.md` +- Contract consumers: `docs/reference/overview.md` -## Operations - -- `docs/ops/scrape_safety_runbook.md`: scrape cooldown and blocked-IP operations. -- `docs/ops/db_runbook.md`: backup, restore, and verification workflows. -- `docs/ops/migration_checklist.md`: migration planning and rollout checklist. - -## Frontend - -- `docs/frontend/theme_phase0_inventory.md`: theme-token inventory and adoption notes. - -## Contribution - -- `docs/contributing.md`: contribution policy and merge checklist. +Published site: https://justinzeus.github.io/scholarr/ diff --git a/docs/architecture/domain_boundaries.md b/docs/developer/architecture.md similarity index 100% rename from docs/architecture/domain_boundaries.md rename to docs/developer/architecture.md diff --git a/docs/contributing.md b/docs/developer/contributing.md similarity index 100% rename from docs/contributing.md rename to docs/developer/contributing.md diff --git a/docs/developer/documentation-standards.md b/docs/developer/documentation-standards.md new file mode 100644 index 0000000..f3c25b9 --- /dev/null +++ b/docs/developer/documentation-standards.md @@ -0,0 +1,26 @@ +# 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/frontend/theme_phase0_inventory.md b/docs/developer/frontend-theme-inventory.md similarity index 100% rename from docs/frontend/theme_phase0_inventory.md rename to docs/developer/frontend-theme-inventory.md diff --git a/docs/deploy/quickstart.md b/docs/developer/local-development.md similarity index 60% rename from docs/deploy/quickstart.md rename to docs/developer/local-development.md index b959889..af89f3f 100644 --- a/docs/deploy/quickstart.md +++ b/docs/developer/local-development.md @@ -1,30 +1,6 @@ -# Deploy and Dev Quickstart +# Developer Local Development -## Required Setup - -1. Copy `.env.example` to `.env`. -2. Set `POSTGRES_PASSWORD`. -3. Set `SESSION_SECRET_KEY`. - -## Deploy Method A: Prebuilt Image - -```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 +## Start the Dev Stack ```bash docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build @@ -40,16 +16,14 @@ Stop: docker compose -f docker-compose.yml -f docker-compose.dev.yml down ``` -## Quality Commands - -Backend: +## Backend Validation ```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 ``` -Frontend: +## Frontend Validation ```bash cd frontend @@ -59,10 +33,20 @@ npm run test:run npm run build ``` -Repository checks: +## Repository Gates ```bash python3 scripts/check_frontend_api_contract.py python3 scripts/check_env_contract.py ./scripts/check_no_generated_artifacts.sh ``` + +## Docs Site (Contributor Workflow) + +Docs tooling is colocated in `docs/website/`: + +```bash +cd docs/website +npm install +npm run build +``` diff --git a/docs/developer/overview.md b/docs/developer/overview.md new file mode 100644 index 0000000..4d9880e --- /dev/null +++ b/docs/developer/overview.md @@ -0,0 +1,9 @@ +# Developer Documentation + +Use this section if you are contributing code or running full quality gates. + +- [Documentation Standards](./documentation-standards.md) +- [Local Development](./local-development.md) +- [Architecture Boundaries](./architecture.md) +- [Contributing](./contributing.md) +- [Frontend Theme Inventory](./frontend-theme-inventory.md) diff --git a/docs/index.md b/docs/index.md index 72a5e53..4a6574c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,9 +4,30 @@ slug: / # scholarr Documentation -- Start here: `docs/deploy/quickstart.md` -- Runtime config: `docs/reference/environment.md` -- API contract: `docs/reference/api_contract.md` -- Domain boundaries: `docs/architecture/domain_boundaries.md` -- Ops runbooks: `docs/ops/` -- Frontend theme inventory: `docs/frontend/theme_phase0_inventory.md` +This documentation is organized for both users and developers. + +## For Users + +- [User Overview](./user/overview.md) +- [Getting Started](./user/getting-started.md) + +## For Operators + +- [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) diff --git a/docs/ops/db_runbook.md b/docs/operations/database-runbook.md similarity index 95% rename from docs/ops/db_runbook.md rename to docs/operations/database-runbook.md index 68cbbfc..abe320d 100644 --- a/docs/ops/db_runbook.md +++ b/docs/operations/database-runbook.md @@ -88,7 +88,7 @@ python3 scripts/db/repair_publication_links.py --user-id --requested-by " Date: Sat, 21 Feb 2026 18:36:27 +0100 Subject: [PATCH 02/68] release prep --- app/api/media.py | 63 ++++++++++++++++++ app/api/routers/scholars.py | 56 +--------------- app/main.py | 2 + .../domains/publications/application.py | 4 -- app/services/domains/publications/listing.py | 32 --------- app/services/domains/publications/modes.py | 4 -- app/services/domains/scholars/application.py | 5 -- docs/reference/api-contract.md | 2 + frontend/docs/website/package-lock.json | 6 ++ frontend/public/android-chrome-192x192.png | Bin 9669 -> 11456 bytes frontend/public/android-chrome-512x512.png | Bin 40611 -> 40296 bytes frontend/public/apple-touch-icon.png | Bin 8914 -> 10518 bytes frontend/public/favicon-16x16.png | Bin 580 -> 1721 bytes frontend/public/favicon-32x32.png | Bin 990 -> 2290 bytes frontend/public/favicon.ico | Bin 15086 -> 15086 bytes frontend/public/scholar_logo.png | Bin 0 -> 111212 bytes frontend/public/scholarr_favicon.png | Bin 0 -> 130078 bytes frontend/scripts/generate_brand_assets.sh | 46 ++++++++----- frontend/src/components/layout/AppHeader.vue | 2 +- frontend/src/components/ui/AppBrandMark.vue | 4 +- frontend/src/logo.png | Bin 106296 -> 0 bytes tests/integration/test_api_v1.py | 4 +- 22 files changed, 111 insertions(+), 119 deletions(-) create mode 100644 app/api/media.py create mode 100644 frontend/docs/website/package-lock.json create mode 100644 frontend/public/scholar_logo.png create mode 100644 frontend/public/scholarr_favicon.png delete mode 100644 frontend/src/logo.png diff --git a/app/api/media.py b/app/api/media.py new file mode 100644 index 0000000..00943bd --- /dev/null +++ b/app/api/media.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import mimetypes + +from fastapi import APIRouter, Depends +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession + +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.settings import settings + +router = APIRouter(tags=["media"]) + + +@router.get("/scholar-images/{scholar_profile_id}/upload") +async def get_uploaded_scholar_image( + scholar_profile_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=current_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.", + ) + if not profile.profile_image_upload_path: + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) + + try: + image_path = scholar_service.resolve_upload_file_path( + upload_dir=settings.scholar_image_upload_dir, + relative_path=profile.profile_image_upload_path, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) from exc + + if not image_path.exists() or not image_path.is_file(): + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) + + media_type = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream" + return FileResponse(path=image_path, media_type=media_type) diff --git a/app/api/routers/scholars.py b/app/api/routers/scholars.py index 7a8038a..f4be187 100644 --- a/app/api/routers/scholars.py +++ b/app/api/routers/scholars.py @@ -2,10 +2,8 @@ from __future__ import annotations import asyncio import logging -import mimetypes from fastapi import APIRouter, Depends, File, Query, Request, UploadFile -from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_api_current_user @@ -35,14 +33,14 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/scholars", tags=["api-scholars"]) -def _uploaded_image_api_path(scholar_profile_id: int) -> str: - return f"/api/v1/scholars/{scholar_profile_id}/image/upload" +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_api_path(int(profile.id)) + uploaded_image_url = _uploaded_image_media_path(int(profile.id)) profile_image_url, profile_image_source = scholar_service.resolve_profile_image( profile, @@ -557,51 +555,3 @@ async def clear_scholar_image_customization( data=_serialize_scholar(updated), ) - -@router.get( - "/{scholar_profile_id}/image/upload", -) -async def get_uploaded_scholar_image( - scholar_profile_id: int, - db_session: AsyncSession = Depends(get_db_session), - current_user: User = Depends(get_api_current_user), -): - profile = await scholar_service.get_user_scholar_by_id( - db_session, - user_id=current_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.", - ) - if not profile.profile_image_upload_path: - raise ApiException( - status_code=404, - code="scholar_image_not_found", - message="Scholar image not found.", - ) - - try: - image_path = scholar_service.resolve_upload_file_path( - upload_dir=settings.scholar_image_upload_dir, - relative_path=profile.profile_image_upload_path, - ) - except scholar_service.ScholarServiceError as exc: - raise ApiException( - status_code=404, - code="scholar_image_not_found", - message="Scholar image not found.", - ) from exc - - if not image_path.exists() or not image_path.is_file(): - raise ApiException( - status_code=404, - code="scholar_image_not_found", - message="Scholar image not found.", - ) - - media_type = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream" - return FileResponse(path=image_path, media_type=media_type) diff --git a/app/main.py b/app/main.py index f8f9c18..254eb4a 100644 --- a/app/main.py +++ b/app/main.py @@ -10,6 +10,7 @@ from fastapi.staticfiles import StaticFiles 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 @@ -106,6 +107,7 @@ app.add_middleware( strict_transport_security_preload=settings.security_strict_transport_security_preload, ) app.include_router(api_router) +app.include_router(media_router) @app.get("/healthz") diff --git a/app/services/domains/publications/application.py b/app/services/domains/publications/application.py index cad6828..b1161fb 100644 --- a/app/services/domains/publications/application.py +++ b/app/services/domains/publications/application.py @@ -8,7 +8,6 @@ from app.services.domains.publications.counts import ( ) from app.services.domains.publications.listing import ( list_for_user, - list_new_for_latest_run_for_user, retry_pdf_for_user, list_unread_for_user, ) @@ -22,7 +21,6 @@ from app.services.domains.publications.modes import ( MODE_LATEST, MODE_NEW, MODE_UNREAD, - resolve_mode, resolve_publication_view_mode, ) from app.services.domains.publications.queries import ( @@ -52,13 +50,11 @@ __all__ = [ "PublicationListItem", "UnreadPublicationItem", "resolve_publication_view_mode", - "resolve_mode", "get_latest_completed_run_id_for_user", "publications_query", "get_publication_item_for_user", "list_for_user", "list_unread_for_user", - "list_new_for_latest_run_for_user", "retry_pdf_for_user", "hydrate_pdf_enrichment_state", "schedule_retry_pdf_enrichment_for_row", diff --git a/app/services/domains/publications/listing.py b/app/services/domains/publications/listing.py index 376168a..f4f518b 100644 --- a/app/services/domains/publications/listing.py +++ b/app/services/domains/publications/listing.py @@ -4,7 +4,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.services.domains.publications.modes import ( MODE_ALL, - MODE_LATEST, MODE_UNREAD, resolve_publication_view_mode, ) @@ -81,34 +80,3 @@ async def list_unread_for_user( ) ) return [unread_item_from_row(row) for row in result.all()] - - -async def list_new_for_latest_run_for_user( - db_session: AsyncSession, - *, - user_id: int, - limit: int = 100, -) -> list[UnreadPublicationItem]: - rows = await list_for_user( - db_session, - user_id=user_id, - mode=MODE_LATEST, - scholar_profile_id=None, - limit=limit, - ) - return [_to_unread_item(row) for row in rows] - - -def _to_unread_item(row: PublicationListItem) -> UnreadPublicationItem: - return UnreadPublicationItem( - 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, - ) diff --git a/app/services/domains/publications/modes.py b/app/services/domains/publications/modes.py index 33629b4..2975169 100644 --- a/app/services/domains/publications/modes.py +++ b/app/services/domains/publications/modes.py @@ -12,7 +12,3 @@ def resolve_publication_view_mode(value: str | None) -> str: if value in {MODE_LATEST, MODE_NEW}: return MODE_LATEST return MODE_ALL - - -def resolve_mode(value: str | None) -> str: - return resolve_publication_view_mode(value) diff --git a/app/services/domains/scholars/application.py b/app/services/domains/scholars/application.py index 5581bca..8da041f 100644 --- a/app/services/domains/scholars/application.py +++ b/app/services/domains/scholars/application.py @@ -344,11 +344,6 @@ def _author_search_cooldown_remaining_seconds( return max(0, remaining_seconds) -def _reset_author_search_runtime_state_for_tests() -> None: - # Runtime state now lives in the database; tests should reset via DB fixtures. - return None - - async def list_scholars_for_user( db_session: AsyncSession, *, diff --git a/docs/reference/api-contract.md b/docs/reference/api-contract.md index 1994943..8b72186 100644 --- a/docs/reference/api-contract.md +++ b/docs/reference/api-contract.md @@ -9,6 +9,8 @@ All API responses under `/api/v1` use one of these envelopes: `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`. diff --git a/frontend/docs/website/package-lock.json b/frontend/docs/website/package-lock.json new file mode 100644 index 0000000..4ff32e6 --- /dev/null +++ b/frontend/docs/website/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "website", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/frontend/public/android-chrome-192x192.png b/frontend/public/android-chrome-192x192.png index 7658ad08124d608370db4e1cc4767c0ad42dee23..83681a84b068e23ed0994fffde1bc90ddb3dbf96 100644 GIT binary patch literal 11456 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 literal 9669 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 diff --git a/frontend/public/android-chrome-512x512.png b/frontend/public/android-chrome-512x512.png index 76ae1eec6680d6607db6dc7c96ae5322ff4b29c6..5720538c58b0fe287b9e064cc557220997c0d7c4 100644 GIT binary patch literal 40296 zcmZ@ zvlAgND-I8X0|NvE1TQHeq6h>8^79A+1O@h!=sMsz{3L`%5{fcFK%QhkK>oo%KyN=; z{-;1dE(}0G=lVcETxmc+nD$xi3OqjrAcj)nB0xapF~m$iZ_xG<8csk!u&Dptz(5&U zSU;JN&XO{skmn%i$S7c74!A!+V4$AL>duM=ZiM!ZcBU5ACWOu&_9lexHugY35)oBu zGV>K2aGMusPGDN~CL9RRwO%TAoFlomf=~gspZ~~|!;K~!J4l-Zzdj$-zWT^p5(lr@ zTB=EBY<(ZrBgxciwmVvB`u< zt-w>+x5BGPQGT^^YCyL2RikTeZ%K{E?T4MMI_fBt8=4 zIpeF$m4{(A4~;VKvM1l!=aBK6DHIXR>)$q3gvoj_!E#rPIMM z7X`u_&i)B**pYj3>8Dg+Pi7rnpRW$}wueF4XzWq{ms0=X2(3zB32(w7)*oknqPd&M znyTOh`WCfqKQkvwZ>tt^YacPm_x3f}RM{6IJh}I{sK8VpqUQJM#2$r}&flxbw_9Jlx*{DlJ;u^}JaP@AX5WJsqD0~M(KotzGvWb?P z07_Sc!y1F+gk@+MOFj}>Sjo$1p^y-pc*s6m zHHwg>)25O!yX2TuZsnG{?RNUn(4|4+#oC7PbYwS2?EJwxS#@Y?U`xIa5_0+{`sPkv za?P%8Aus1l{lz*%r{FD_AfTAH?T_xp@e^9=)n&|kUER1I~yA~n{XL9n*1a{ObkqHv|Bf- zTnx1*(_v zhw^|VMFf@I*RQ(FvPi@ezCWw333?hDw%?a5yH>`Df`K>%vIN|buH(3O1y+LwGKE(m z?XG3_86e%T41$pzM~P~lIy7&(GVvR@ecK;A=2UdjRWz!noM)sz9}`$~lF`xAlaaB1 zE(;_hB%xC}vapYUF8548M*&((NPoMvNZ9-6!LoQdFFAgNmFPc015uKF!q;2)5dU&&I*XJn!%guil@!SPwM% zah7ZULbLqMY+=gJRef$qF;gEAT))J1Xu+cxq~!&Y7wL&;)9x?Q9(*)l-iOSOGi|;j zk*iNgYHnb-&MFe$93~ARd6Oio&7yA28j7YJDF4(KfDH4<81cyZ%xa+<)@l43uGJzE zFVYiPrrociJ$>mui5TE3TbCg2o`&D3iFpvKi8FYm=`2y8H^4%70L~MJy_k|&BN)bP zy(dVpuQ9Eex__-{JxHr5B2kcQ$Tf3>LuZ7;dUR|he?T*HPx#WX=87_$-RvH|Q7pE+o6WL+Qh}2@(7eD+tr#%?uPV`Vp&8{Yl6sW{A zwY53pYZWHA=>sde83;~9{=up}>ay7jlQBNEfFzy~#50=f9=Nbv)QWl%*Lrmy%yKPv z?6KMVkcQK8?4KW0wK=52V`cI?Jd=-q@G1q;v0rZjMRy#YCer7_qz{^450Pim;X-IL zyYRPdoy3m<4m@#c_UK6yQwt2@>{@L}{B^v~u!ljdO+v!=4>Ly)x_#q4JjN_ix_7Se*T7A3|P`@0s2XNi#h$p5Lq6MH_AG~}AFy0z4_hqkR zT5WiK%EvZ)rz#_YydcmK9?X3EJTJh4S_?0bTcR*H_i~3P+R~HTh*=Eb$g&RUe20}( zGwxd(I*?l;V1G*cs9V!bgb8U+?1!KNqLSSaSlbYGd| z`)B^R4qAuts9zDAgB$9|u;20h*k6NJJgD3dDuY)*68-$4xbBdtgHB&W5eFZ!-=?V8 z;T^sh4%DmtJEJb0NhiY&pTanh0CavMuV?Yi&oCd@w0*rPCwKZ4#0{{m0oaaL#-2m- z=aE~C00W(O?b=nQ?ICJxJzHNxIIQ`~Bk@fOQhfYwLk4TB9Oe(RdyMVDEXM~_qu#k8 z&<}sedMnTY6{Jv;uA6Dj}Q92flZ{Er;%BGME+er^XV@vf&xYwOG=wdAKV*b&o)4W zLfz?sZ+9Q@n*ZJ&A>KeC_DfjOJXBGK=1#Ad+ynh*Y?BQm4cOWqsjXf13yWKaO3OIf_N3lwsHS9Q zVhN+10HF0w7~t^xuzx=>4EfRA9O&)a=VFl#4tU3g)%ziU?_tBZ)DHSV>+R<5%kN;3 z4jXh&%7u+D?+NOGD!nFc_F}55+S(ez!8+gXc$=7uK*BkGIdOmN4213f%VC6o3xs?{ zlk3;V%om*MqWp3K(A4C&Z*C4^nXV3;0G{*_(Fi)TwD$eL_4YWAj!B1WMR0eHH`W&S zgH+-nD0hl))$T02kxCw871$o{toR0eu__B#DB9SDdLp!52@C(TmO-rFtph<=AEk6q zyqVI6*)6#4uV4Gvg&?(Iat@FdSl%G4tFKLiKM)5Qjt?lrp%JFGN8&+@Z2I;NRuSJ# z-Z4i%$YOBNIv0;QBbO72Ow1@;5sN!xfOO4?*$#$I+Vq98yw$Np6p)9t3q6!U2DTXstpfQ`jbtD5!oY zb;f96EB%K|0rNHdz6P31Zl63Xr}|OhPOcXn_ZZ6K>y*x`}{G2)&GimbAY5Ad8z_|sXBXu`)b;G{mUr);H9(2F&4c7jDY6O4|~VV z@;_r09=?+PI%H7}d?g*9^8b7KkXiRSf`#FZ|E)_|p>3iD)CSt!J$3n?R>S^;ecuPn zEQg+c8XA<;lMw1dZtCjS#o@?Cz47~9-hbEcEBx!JfEw-}0BjZGn+S}Ca5R>$P&Y&> zw&?O=;PFyVfEbQ7wP=HI2)?0<{7$O8O+Np*&2e~>Vb))Ma7T*3CJI2NDta>``$|7->!m%pGL8wX z1OYCe`nl8O&B*uD3E_4{y<-!O>VQAc{R0BfYs?RQQTMTPH*|ogUvZd$f;DVv#-l(o zNPEmabMD*8y?x;i!s5H{p#GseQ8FOnNfvLEz4{bWX+3%y^&9&f8TBb-s3PLI#y#dYILAZpFL~-3tXV_~ z&+5gRS3y*M5ZsQ=r$o-S(2}i^v*x&#`n3opNF^vnWbIyy_Ky%w1hr~*(9OM}Bj;_J zsE00^5L(;`m*6EbtX6q|l6g;I0S-be+zna}om6SIsr(ZF7ok@{z0ng~82tX(Pa19x zCgfAneXM(hS-n;0{ooynOaJETqw-|oPF-QtncV|u5u{MRw@hL}V=btSkEaX6-t0u~ z=To1AF5L(1+}2sVCA1V#_7@A+8^VVcI(5_I0PmgMR*R|o;B=`_%7?=|*XrV`XbG`! zmw&m|1+2BcEj#JaR=vIpz6`+~^~AQw>``lUaNKO4u`M|}2@&;ZHqRN8(&{Bmc;1;( za_en-mHmDw9(1#y@^umy>`oD|a7WlLUUiMCl3#StZieswL_g9Id>Rus{wx)>YoJKU zV$U_WLWw*19^T+gaifQ=pSyBwi_5XsRzz>zFhkv>BWTUE!Ll#916+!Y&PNY_MXR6J zest8DfH^2x+ip4e)V!b-7HuAMw7iP9fPZjAuj|D4zw5f(`$Zi=!C9iCt~@}WKQsgP zzx(Q7_$!$O&k^a)tfmAi3jy(BE`HG9zWlq_c4tp2YchIY4%$+EqyVv^Ug;BVq+e7gXerU;o{D%QGH_`se-YrgM^6y~| zL>4lxJDQ{;Vv`hbciTRAt8j`UDj2$*u#~GZw8O-8-vnYj)0L_;!5dZe_il%i>6MoB zJmunpQoQYkmm5(^_;rzlSmz-}u-)fHP%3)ur1Nu*Xt@`r)$Z=nwrjbVr5h^u?lVRm+*K#_h_wVE4t=)x%{y@W*tg>8%A%ea>PtW*L zEyQeIZ)U$cQ=U%L#_SWMGXGDZ#$l*+X;24ev_z9~E@IBWxuywWd(!t?mkr!H-W#n0 zHn3&?8l9kaNL7$5b}PPDGskI`Z|Fsj?#*3P(QJ$E-m|vzks;(uDL(IH9_qbL1wh;J zu~%8b3>XWzihf1Ryv9shH@nC?;Lbyp(hwiMQ(7RMvgate^ycw}vNWGvR`>A+=9rwS zf9Bd)CCPr$B}mzYult3ND)c0EGS1UE=aZN_JD=63v5i$j)((Kys6g z?7dBz<*52dY9xYBL~VtvKPgm3{IzpnD_2G3>ZP*7r7-ySdkYk>bC#W(pGuAD-90ea zZOT4|uIVjobm?*3s7tv#)H85v$)c-u*m`V61`JdDzR!meR`)=Eg^V+uAn(Ba>hZOI zNKE_ogYgUQQ$9<1o1IdU$F!errC!>Ci{p#Z7iz+8TX!ngq{IbKJp%yp9wNPbdyBiD z1p7uok)ykuKkMNL=;Q^9Z(=@#MH3#y=40-nAXu@#!S>p-+<5a`EgM53)k~sR>J>Aw zj|X$+qU?skz`D@zR$qqk&_jNRzThj%eo`r&R zJ#rs16Ra6e)IbR686FSZ*#~XuQktd^wxb(}Lle{u+3%W&kv3o2e@T(1SWwiHLTW2< z3H((}3KN0kd}B0Q7Q9;;O7YT|^>nAvy`Ed1c};?m?EY!|u5KZ}pBSvzGU9^Yt@)%O%wfnoG#%VU{0+9lP$) z3LZevERX9X_nd53Xw#D`i~!6{FjH-*6Fotsj0I|&L<^4YiLAE!@3drTi+6)E3SZ-< z-07lbH*V#YF5DLSyJoBJegL$03TOVG(6VST&@%70(F zvo!2!erpb9+qTW~x4Xd&S{F;<-55oo3v;Jk7M<={0C8r`hYyCUeg2jg&kpISXRlY8 znorEk0fb)>0-D{e!Ep+h=YUiEt$1{c#a+&lepQIKnK~S@2aDpP39Z(Q^WQVh(J4aE z!BN&ytsH}_;~x2sYw?F=NnX&?8}EmryoIgIF2rFSSBrKOkhWt-1I;11di~8oON5TZ zziF-J&y>yI+I;$`rR;WHh|0yyY0GL;X89@;Yd;tqXESybD-#&_CWgx=-#vW4nqpyk z?xc2v0R210svRlS(R$W-bfMK)w269CHU@AlB{P27kJTOTYqee+fu;vkE*lWkBgw+*WyFEbidy z04D;v0TPB>Kcjy&>+w?*Hxjh)C^=vu$68R8XuO^PCLmqX;M45nw_rv#JlMJXZwaP@ z>3iMW-{+kp8hd#ax{ML+UPU7L2%X-$zaCDOx@F}g8+m=^3QRippEsYN>L(95IMxPs z&B#S9_i#O;E=+Wxgjdlak+Kj4w)CrV-*KTv^_3sc`LMcO74qd;PH(cGFy!n~wfVll z%%Inrj$CzART~34?RxBIBJ^CvYOIDVf0?PVHZB&3fQv*O-2QqAvLTU)`0NZdi>9&2 zm&krN}UcasrW5})ZgAj+)F>|;Dg4@(`B`*VLRK7 ze=H1eSnWR&l-fm|W8P;Tao2#$I0bGW%zd%nik+@x7nL+X2C~Kgl^_yME|O6EEsK0{ zEUV62p{jM0c0H5-pu2YKmF(twRkSa2)Z(IBWCyQt80|qJ5*gJUKzu%El_Fso7oSeO zL<3V{^$ZF1XbR|P_GmJNZ>g=1~{p zWQm2bK&-)l$~2X>WA@xguskff6rJ^mx?~{$;g4Dy(-LbtX+-Jl_gxB+xOwZqFN`If z#Pz7!O}hr)rf#sM)(d>6Q2=?tR&fWhWliCp^3p3-`o*5wg#LG>iIK9yzh z?VJet_T=ggZvM;d$dGks!{OP*&9M3WSL0domJI)8TBN09%=bw=+eJrY$3e ztU;5f7BBzEj>2B+g&FXxL*+b!fINKa-SllAf48?st#QMF-*oPWv+|H4%=>RV%)W}l zkO$llO|<-2ls%sn#ukVak`&Xywt!yE)H!$$kQw?U2+}L5Qd^yi5_b>&6K8k7| z)T{xz@;^^E65CD4MD7ZJdr$F4^-gcglJ_fRv$;!>0Lx$LfAzQ@xK8OBagI@;b3wK# zPakvA)gB7+s65!SIvRWYmTs5s%Vnop212Q?Y@zHTeE|<$8|8oPpR;$3kpNySfjQ1$ zX%J(2_X+%Da(p* zAM{ql?)@I-xgQEW9PWTvV<+Jev%k2>li+pvW&G%pbo*@d3a;MP#Z!o9diN#mliTqi zt{V%j3IzNCj)1H0Cnbp)5ai3p843;q_OQ1$MmCjbG8i>Z}Zk1 zpib%KRGF2*Uwi@da8`bn+VOfEL}QNV(n*!PcC5c0MO+k>6vpX6ANhed0{6;j6F1aQ z6-$@KVh{j4pIxF9igLi;oeo{PZb1c{pPpc>pTuW9z1-d7mgag&uPM-dwtaNK@~Dh` zF(Oxjg%P`0cZ@irKJKRa%ac8>lB@kMt zxUPgql(-lEx5VBw7;aI3mEYP^oizF0sefVuPg`x^?Fd|E$}7IEH9B}g*RO?i=o>{A zYf{bwimow`G!v4O%r(YX;KFbm%ZWc*RDkPzgkZpI8!T0T`@z zEe+`~=ymhyr=XeCqo2}Rue`=Oj*d;*9D7x$`CjD)+!_=-P5@ z7cD-m7yyl!@0MAqJeOvf)SMPruHb+cTx<{V(Y$(;WzXRPk00W0XWz+;DpjI0t0xd< zzFmuyIXQl?btu8?>viX~g5z?w#?9ElSs0+Fw#U-$tH_ekXASbzRcKdWzbrwCq@%+F ztc=m$Z&fRy(FLV!!cPg0PE55Zo_om)Gf@iBh;vwE3 zg!r6{o?zX=feAej3Q4)SISLz+Y2mic=i;05Zf6i6u;S2_Bk3b{=gnQzAers#GKE?U z-L+0lwPcS#n{732RkV)Je0<0vjU>x=;F9kGe@u(R{?^t#8RJA>$<%9+TU&zzgI{CO zc%D?p>EvkXXGT0C%BC*)Y_m$*kS|TeIw1aeYMeo~Z4-@RE4Z967mR*b1vJkP>NdaY z8>R;yiS2qIGhv6I;vI~P&Ce}8(sJ2ae9nFSROcMIxbV!m$gN=C9?C51xTLp!hUA}uK<=|-_l?a!S+UV-Gfd0usSTTW zg$rM3w;EOq)vmeb5DK-&mX4X`#ZLK`t0z2)dsJ&?N|k^yw+#7qbJK2-d)H!1s3!i+ zXf!5?GsuZ5OtUWwAwRhOKRCg6lI{GR&y(!^GUqmrMOy=_7eh__J-3-QeC4-hhgkjl zRLAZqU3Zv~uocVbmBz8ISK}Vq_1G^VP4y#cT+}j5vH-8ao}4CBTvzMXsbq0RczbCXbXtD+d)OHBo%i;H{kX@18zwHXJsvlCKrNQ7h|zVI!dQ# zK|L1eMem%KnVaM=+Z6cHjK&WVTk-^5i=vb+i7E2ZtHFz$n}vq``Jwl6GkYri+$FJi z!x_Im_II*EoAJb~4mBAPtY`sc)*Y*~2UUbYu9{e!6U0%$DTznxO39`mczQd>!`G0$ zdIB%-LRat_Q`9oJga=6c1AU=J1UX^SQ2vOw@QENn(RP?L#!IUwR|G5#O%w6#(~K za{@2YcVr2Z^T{$aEfCaSl{;lxg^iuC#>j8T}rl)*;VB`7UrDL zTSr%2;Tm53XCKWrrU0+ifVS!N5cOhRGF@VqJP>D|-iE^=fxQfTRk+PHIY{cn< z`1QC6Un8qPROVdcCLQ5IddozP&XisyvzW(H@%S$;p`RsaoeYzmH1{CNneKiKc7A|3 zQn#scCU)L!N)sjCj~b&ZnI*$aAo~CvAQzZ<-ouN{f8qI7{2LO(>Ja)OeCufM>DoTl z=5dKF-o_gvuGz~Yrt`3Et`CUmunMJT~6Kg=2{W3JBrZ68!R#a6kP;QAJ!;O5YL^6!(x&0paw*j zBRePeG*N%hb6)Ad_3Ot;;Tpx))Heq}?UPW%_IWXm-B`4EI&*adS7d5Dr=?Px_J(u?PRsHl(@H)KC9WaN2rT#>0XV-bfele<0v~Q+i*b} zjMz9tUuUACmpzy%Z;|QaWLDPUxn@7l=9iri{qDSmc3GU^OP$?b&;s+NJvlOgi6cZO z6L;niopG(hspjVT7LeCE>mu1s!c+mT04?|F zvg!AU2;o3e0ppL^(2xIb-&%s6-jSieh0M(O@17z^-%MHZ^B4MLSm%0$6%nUJuvfuv zdatpnQdB3-Q7Oc1P)Ux`^FI~3&`UQ&AUmWj5*)8f0y?Y;K48sQXG8KMlB+jmN>R3u zRVu0o%JWT?3yAs@J&*EX@_v3PBIG8W-7W=4l-Vn4C2qmreS`=ka761Pk?i4?D)g`x z9YN#WD~)+rvm-dA*-PAHb3M!=Cu+-pa@?`9yvLcco+CIm-GdNI5A*KI1IsinaBkcI zzX?Ni-*cSUR4fh;49%nBgUuc~*$(J11}&&P{a}B8pkYbS;)`C^~q7 z%llTiC{zvWUkHqtT>CT)<7$e*8kE*N+>u_O`7alEhWeUIcL#Wuh_6vz8hmu=s*E8% zZ;s2*t~Ox~igQ#4huK43#V+=2t(AQO?&98O#BW^;$`ux!7<_Ja>N!StmZ;x$cn@Bh z?>4W{NGwBt^QLDBIU<_5egqbM4RF10R^eJVoqpgzfaEMVCp)t0Z^9>GYy!20lklXT zaGL4c4vd`G{24ewd})AQ@#$(H@jK8hq5&CSGkLnApV7bSIPW)T6^Jjrz@JkS{{wn* zDDjlV@qOd1nvK;;tHnR}ph(=#rr;xeoyW9CVbCM^nR9-h%S|RwbNqyARrAb(KSA6I zxP=M z(U$9qA=aDXxaRa2EAb5>YLzi@+U}A+@997MLh-eGaYEzd1~Zha;Vn+?oRX)Z9V2WT8I16k3ly5Z9zQ4C1MUR6lpoRaO+we!=@N zZNkzLB!SPnso=iX^)`J09qpfJV2Lkkf+5~@F2>W*m4)&5)+Y0o0Kv^V#E<^2`PWiy z(WOtxz|7hAxbF=Z7b$Yn(4`xfA;IuJ-L`6~DlRnR0QtzUIv_ZIJ94HPv?P1^aK0Wo zjA*2n0!QLTKM(9gTk#YxV~2OLPIxWynd`Bzk7{e^8;610f6CC|hFQ2c3Q>jx7p^Mp zJH<*TvSB&-*s}0N|C*SGu_^Po0FN%WcDUau&O8qVL$IS{7Z@Beq#X~?VE??!PTp$r z%&HvJs;^Z_a`s%`d|0ZULQC~lo~L4KNpro`u4=nUzs}Xo%4TGwVm)kz zN|hnL-&&Eq_Y4K*e^PIno3vhYuiTn&)4YSD#A2*HA*1C$Vjhbe6wr zs%xNUI=H%nv_@||Qu$Mxoiu2swav(&5|j`%aFvVbtWQAg5yUvZ=UoMO)GDFMRn=R4 zkVE8SaCVaUt-G1mFFZ$c7rJgwiuXTW4F)N_>zT;kPT?5eJo`!_~S1ZUk`>OtIi5TG!XUdMA*SZ=8;&t8w8($;= zOEmPPWJW^(OvNa%;J+MnTv_i$U+enll~?2Jd{EHZvNq_gO)TQsD@K8d%P&eFB^JgU zTx-*-vI=$@ruUGloWd${Gqk4w)#f(;9=b`id$y3lZ5wu?s`;F$^fqX;X~f0O6{?w1 z>$nnm!bw{zIfjupx2M7$c=IC6iH3 z9V)^Ko^Om=;?l)+S=^Cby1q8kq4MMaFYwdLmx`Oqaf{>kZGy`Eqx|a1Ixu2wBSyKAnM;7z52M0bX0%j zf_B_-Ju;31w6B!|_#eCV_qZLloof%BJtJlH?uAGDlpt#Ze63Zvs1iSZymG7oYmA1- zqDe!RrhtV&NAXaIrg-bib48zVds#FgyKGwF7@lk2hh1@UGfSTw#9g&s@z)HuvRx0$ zZkXQOihY>B%zugDdbXztk^_A>M9v729#1Ae;eQNuC8{kw=?RHZpMgGU@|##9+2?6w z3qm)RTZi(d&G)(InINN|M{ej>&Tq%MWK5^kC)cs*RPy;U4Mh*W8aYYs7T;mrn~hYRQ`M?|Lj9f)Av&XrQ+76QniNeL>BjZR{~ga zc9a0I-<~@iE%7ir$U~fkEj5Q1@{#9a=LbCGs{iF=iOr@Rz}>G2xGoe!{qJW~tr0nU-bJEmxKd%pz0fqM36ojv#u(4mY8 zQQ~)pL{Pr-ipRrL)6Dvl>$Z5PQh#G2S#XTMmodG(!|G&1g~4rQ9RbbMr_)}ZI1|uy z3&j`5u(>+^B2FLYNok0NO&2lJS5DvZ+*==kavwc8Rv zPpP2T#O%Js1guGR`pbCCb|mokQ@{Q?@9!My8a!hC3VXRubv&-jI8(LG;ZkzS;l0?> zo{MQWub%T==j_-0i8STKD{2tf@uk$4A0atz2#iwiH5QX+&AUNlj)97OX?!=o#e6~l zeaQH?K9UZ9s@ccenejY2t|k!q>ps^025Mt+k9hS@!+;Lk{88Ld&#l=8M2P)AI+D=* z08AdmYvf_C9gXoAX6uHObE4hj$79@QTRHjhZJeq5ig(Jj~z8Q*@h@jZjBMo zW{0fm--|x_3M1+mb&0Gr##F?SwYo@n2n0(ou*G5|k?2t~x1tP-==V>z7n&8&MA(_)ae86mayb!pr41# z(rCW3=KLH96$kw88gG@`q8C*_^$HwV89mC6bNzyB^#MNb;ycUjSZH;5&Y@K9 zaywbG=(lCl@R|ZTGl;BJx^U*o(=Ko1{U%^}?^l1BH0SbrS{y;m)F-%H?k&dSJp?5f zvoxsp;iNEUDu}wWgWIJpgZqfzy>OJc9&JDrsk#0!K41I7tssEM)+7Zob%=8U?}c5H z4jRf}#@t40N0Kx1FR5IwwBa2Zuf2(3!Ozmi{At-OJm@xlKl6dS91EIb6jeu}gHmdR z=YaXCn{1C?_eDyh4Z8W4i5YKy?~0;_m*+cO=sF}iLvzRJw0b@rkC0t%hVALD-7iMW zGq}H3gW-G{9pkGijZnQfes=4;`IVV-LMjbNVbg^Q?XVQmcuV&tEk2i@K2lI^)av>A z)E!*=-B(uZOoV$shpFG!%XOFyV-WS}aTP0aOECSK=I^rxd`M*yjFV`cqa1wNj3+tyYP-E5a$Q2F&r2tCTsWSt+`#_tDJlK`>u{% zv<3ivPZS+w&fC?SkFMC5BrBv>yABPtt}wIAWyf}vwZ0xF5>D4{j&zsk1wi-6Y&y$A z0HujY0o_;XD(w26SFv@k^w7R9i33&#nO#iUkbCr z)R1faO4i1hHlT|L!pJ{dkV>jOiE_*kn!$r{*3VJWB@|L{r5Q1Q0r>}*;_209CG(;x zem<)%uUr*$&L22cYhTXa{eo>Z1vYd<#U&7huADy?r|4`K>xTrya*h^}V2>yv-#8~U za`slD&5$SB(!iyQ_yT$LTYVw14-}YHj?6a0ZuCMXRAc+kGbj!1=J{yAW?zAD@*}7_ zp~npW;(u?Qz=tf=8RY-kP^nht7M1X;0OlLY4T62+m;h$0O_9O-S5%ql9QK=wb5!u! zfQ4COtf*2SqEHf@!|0_4gO5M}o?=3mb}3$c6al@MWclCA@_d=L8oIwbH)Yxcik(mj z$6CKhUM>k7p0jKC8{?%!h#_6G@##K`m&QnvDTQ40JdrT-ROJb~v=Mk}ePmlSr6)`N z3Ix#>+%`U%nkLi{{mod+$YQ6ecEXp9Q@boSBtAy(5mdAk7UrVF+a{&2gR}2aBnIoL zb9rfwpT=ZVMYOS{1sBJ(>+(HI2M2P;D+JQX>a^>%F_lzn8mo5iYho3~X+A{kT3@$g ze}ZBcJJ#}T)~A-kjGiW|YL?KPTIj=`(^5>!T`>bar7Y`b!;om9qW1LT&DjB-_}aMU za(yNFB@s&+Iy*K{w33LAtg*QIxjli>EpxB1TukIhM-n+?*5TB>7g2jX~>?iR+idfaU5 z6w*+M`5tn5HP7bcI>|pR+gu;Jg%=y-L51*{wU1_1X2j&AJ_d8loUEYi^&GQ*_3m~} zOUd?Gy1AO9cfuzU0G#@`96=|nsipin2{cR&a5KY-`L35}llsKq-i{Eg<>pz zk|m>{8abWBS0dbL9?<7JxA}8!&RqWPwvW#bQ@T9HUak)cavtn>3I#e(n`WXjMaN{w zX-|BdDHYH8i=2y@n?!qAiP$TUcKoGCCQ?xlnWv-q1+NtDpqF+%H9IEhddneK8^l~! z#~(a9MCW>EHrx=uRB3>$gq?pyE0`n)d%b1nBNNmsyeTazC=2F zKl64_a?^i_6^?<3^tJUfo5u)SpI9t6Bpt7|@Qch1AjjSU6{b%(s@6qnL_1n@AzZXG z-*>8Mo_@IE05pG-VS8O!m2Ea)&Gu4tluoZ))oc_1?yvkq3|Taldny=oym8e>Ok?LU zGT#S)ET-*4OG?dfCR)RjwR^$Nrx*$x<5pxvHd6IZx>0(vIFmgq4uCaC5E|q5Nnhasdm8Q@O=8=bFG|3%u4W2i5 zu3KlJ%ilhij}WT=z8515V!JuPs zWZs^Js4dFTCM5uAG^3*yh!f0i8(vLz{rlG*jJ$exg%P32qLb z0z95g$2(JFq5*H$+xmJW94H8)m@#*W=0u2WUcK?3G1Z40Dnfg~gbh}I72PY)PSd!= zQ+dXaat|3L1k6qVx=4QR1D+A&h5CrB>>yn?mWP0S4*umX-7rHYX{9Nn6IQ0UB9os; zI$YS8a^B@b!Z>vY8hlD(eYAlR@FcjpF*(RY$=K#}ls$xWT{HU2{5WvV5jI+7Ui_5Unc9C6_yaqv&=9- z;FGu5tGV2%nrH{dleJT43fy@HL(gQUYfU9u zxlYBDE_0dLIe(SW`~yxcjSbw3A9H+&s^ky`wfUzwf4Wjrr4f0Fp%cHDWWq6xnZ}4+ z`lygelE5;dbSrMI0~+{8Q*IoxCV`}pR;CS39h(rc2tVI5VCUqydJfr^6a#j^10eT z*xbbOLO=~w(*C{%q1PxxDfl4ect;UG3m#+43ym4J*pXx3=z5}yU+x*-MmSO$KR)wc z67GB(aQC5^a@Rn6_~q*%pE`XVTHZGKUASHlRw#)Pu~n~-xX)`!@n?~(s!B87cMf4Y z1sry<=>B+k;`Q-tX+ypWb(2tJQ3q6ruvTokiVeBrLrQ;n8jy>gbijjHxGks>FqFRs zpp7kC3$XyE2icxghP-5Lij1u;dEjS=V4+7;X5RV^Gi94p8e=zLqcXnx??k38)Ex6_ z)Lt8V=SWKb;*4d+a#fs#I0mmf;KP|TtU@zw&C7L-txssOHlQ!rU)*4%BJj!D1+ywM zfq76LLcw4pwqIj-+dV6xIONF*HM);aS{FM2am5lk(OoKr#*FRMv=KOS=W2DVTN3jN`pF1S_(Mdce^W#PH)=) zz+k1r6DPPm4WkJHU+|v*UoMVuw&nVq>@0^Y4IJX(SrK}2jy`@Y3o%3*GrEKOX2=gm zlu^o;EGrjmtlX@>e87}Q#^1Z$>@bYve#I;x_D=D#rWYU8DQ&r+;C;an3-y*5<%p9vWY6#UTp| z@tbiBkonFSr#M1WxA{9PhWso6>m5!415CH=HccTwWD4oH+VuaO6=e_bOfXyP|C+iB zz-2Ua-{BNhT-BNy=ej!oM`2xCe^d^C1i%iR+ku2A!t~qQ?``K99zz zQ#1vjl2yf?ve=+a{`WC5#T4|UUaOeY*;Doi`^mSY*XR%c`3ijAR)C(&fGL8@dlWn zWRCn*&$-Z(q@OHX&I=`5>U+VWZW2WCSnv|B4&f&*lChl)$EZV(&B+jNJmc)=45H{X zr4yR}LZws7gQ2r(Wgs@V70y4TFkBs?Lmz5?vKXqC)1xQl<;WRkvE@e17V8dwjqYjqJyx;d_A(-6naVvA7LeIDw@RW8RN-a~g%0(o{= zQ=hGl=J=|!$$-V9f>hj7f4s7#>Gu(RvVg|f+U@zPq@?axfJkw&N7K1FX5j5gQ+SMl zWrZV2Q?YHYoZjYTI>S~J)rJAEK$>w&G=5ERo3cbBXa?JJt){&Z(N$KPJBdOxEU}|s z+*W?+*UkLVF;oO(Y%O(3;U7n$+Dpk4(lF+i2&#%zylV|Mq4IZG;!#{-F8-$#(U3o- z@g@saV{$cgO{xZQR64+nXpuyZ{82kAbb=ca9rk!En&Mn!##+Hqk?}2|VRQovk6sD$ zi$8M_M%WYQ!4_O)KzUfNB{k`|@^q33!9GR1IwTIOld2*S|C2qE zi2jHB?af&Bfs;~n9GM_+ab~*YD_W|SrZRU;DVbJHG=(yxemm!9 z2=`Id_?#jyDWr^hF@Kk98Zswhf#;$$@0UjA_9@Jk=1A7faoRv`zwsyBGVkSA=jjXPZg0gf0tpDf1EJUBh2Fnj!Fj^7B-yg z3p>wn_P;+?nE7e#Ax8GWVvRlERi1 zRST7$OY&T1+{36z$}%|fcesS_7jdpQ8L=wau|~>)+|=BenukX=V-(Wd+FTq7v0!rq z&KxP61g?2GVQpzaA%8nPCj>?!d#gCDdamyLssf#L{%P?Cx438sr{x)GB>R{ftFxfQdBP2NN^|y9Kcf*3{b{b+l1a?% z<8G)gp6+4B-)yM``A6DU*^0%(m~yiRtnk^Ba+GX^5|+^W_6z~^9G;6aZV$~_&gc6y zReY)+m^mbP#K=V1<3xp{(oSi_QvZhgJ6@w{vn_H0c_u)q<&N#NZ}gs z`w=L&C{0|&*BA8A7`~rh%K8&UNsBXc4Fd8{W)Ip$M>@o8yQSS>iIX4m_mlB7r#KJ# zegu>5XOB?fH-xMy6@xS+Ml8g$`&$YBO{?X3w74fW=O4fwdqDGt(I7D1S>=@1K>eHU zD=_ge?%}P1J74;&Kd-7Tjizk_D8bW79};n1(E%~mR+oj!$bjNfz{Q0`4mDKvBaE4n z+*T09rFm{kAFw*(rs5Hl{jg;)!X6pPVvc;5-G}tOz#*oyeI{Nimu$uzvL80g0nzhV zg=k<~9!0$&3mbMGJ5w(#O1ZSkEEfU(S$+e4MPg?NEknT(ioYOh%c5-23UeX*VDJU5 zx;@9e{ZA;res+7B=>~G8fd0L?_u#My!uHiMB$+um951f9J|r2AI1tzZj=H*Rq?}i6 zY)%?=bZt<@yD@FQ1%T?v(~mU59{tQ>g~gdI7XG=3T~cT&SubSgc3OpB71gBf(lu&K$D-t|xFr5j$+*6;B!ZrSlvX`fs z5-axC!~$CY_6eu$MnJhKTnl1vA#k5;7nh56AwoBS{xGj3+n!egS6vxv4-o=<4`@w7 zVh;Uk%EG-p*ZQ%!NyuwFFTu$MLpB=hrY%X$nKK%g$q`)xF71%|jD0^p&cAL~l9QHS zp2K5ccgS*lQk+G$Ajxr0D~=~$TV0VzH^QDd54M8g%pL&BaHe+b6{uJaoZ_8U<_tR7 z3U91R-zDo^aRA3zATT-Ylm#|*O`?@8p#`U6SrF$Q zo?`Ye9FYP$)!)TwzjeMrWOTP&d=*!)%}k!5Q*;!{G=cn|nyN%M$bGXUoYkUZ&!m&V zHFuT{<|!Bngb_$ zhKr6p3c=y-dZ6tKSyN28vg{+|Nt7lT&T?uTR?(ICHCqx#8~#ZL;HDS+NsZ4+ZgE;4 zy*WYI$wt(cX}v*#JyoCOz#KfPWWDUE)Z{+qKN>i3I%Jj(s;-Z&UB9(5d(od6^lfSGElI8MGwj=$10PdP6 zTfmjsTM$8}Q?lVmQ70>e{RnULIt<_+RsY!lQE}62D7vs{f&9e1$785iq)~Pibf7qaVOxsy+g*y2W0KL;0ZXBgOB54U;BfGb0!O=&s>7L7DCLYGkF(;m zc!UScGW`qWnftT35aJEpaHyb;xPg9Mlh`b#{~|o&@#i14M{fwfINxEQKdnRh%hRPU zO}(Ep6KOOiWWy0ELo35czTcMQaeioiX(8-vU85c5PX`Lc0s>na=ToXYutoO`7*iJ$ z8oYR-_YHN@ry2_KpgZC9tvXVB#K*X(u_Oh)mP7>nZwJM8!Q0#^H*&-WQv?}uc9 zhAG9-%5tWh@Eb!=v!@swP+c!kI@U(JUvtr5J0`S;tv*@ND(a1CaiUuzUfDEjIN^Rv2o>kkxX=abZ;Byq6f-6Nzsm7wzel>*H8x5#FPlv2RCn%YL zW_PiVI98@0tQHi5o+E-2I`D1cZ{NI^=@iDJqi{{(_0AJ*g_wQ0N7sf0cy3wf`E?iE zJgLZ!Siv{nvp(Utut7{bX6M?B3c71pop`5;kkI_3{!CLwK(#H0$CTbv4Y=}?2D>{# zwJlmR;q;?IHk0Tu@Te&@UVla@7x&oHTQqXRF#~`{T|&N||C#qd*rY-#RfecN+0lNV zeM5iL#(TjiwetJ!bXVj&(za8yccGv-TS2}Jo8amM8Tx{fZsd1}+YlABZf{Hfm=kA4 z8J{z%HKIn&p|0rtG069OyiCXh2Aw#2f=Z_;_gN_w<6JG#cDzzf-TYz+FtJ4;lpjF_ z$pNq;KI~m*D9gP8+tRWf1721K41P9eJ(Q{d=Yyn>7TpreEV>qbxm3P>MpDaFUBK&r+gU=QXHmqjd@*%dK zQ;>iB*_ZLN9Q8E+Ok*A*u;1#aG5v7Ll7o8sVvz#1IHj2sO~YuMCJ{F@T0n-gh}+0Q zrr6-_^M~7}E8*);7^I0~)~}q)+}Ur;(gKRYMmoPNUXTH`go8kQchc`9X2hz^8Qt)O z>gi@Rgqy{;xrd_47#AcqoQ`Yk)t3F12jMM#VVA;S8+1keaSaa=6$P0e0(w(665=Qn z(l`VSF%TQ+G;%aB7uIdIMwU-g15iblPL4cIlMwta){#~!I#C!uprKA&_=j3t_^;Bg zg~_nIcTae-MU2ZgQ48cy~skxp|mBbHAA61i$55%kHAvwp`fvq4nWZ+I?7K16TB-lUaw zrLlfSlFZSo69PQ0AxAN&uKU6dynh=qUY-T>_(|h~08kt)3lq(+Is8Tw9{PixwH1X0 zZ=bmrsTa6`{hs(9pvm?*k6*Dx#Hu1-+A$4RRgXi{8I4;l3Tg5m&pChfk2q6Eu+5j1 zSRv1A2NQcTy?1~hKfZr(Kj7ja$|YztX;o-vR}REP9;UO>oMP&M#de5blPh7Hkgg4; z$7*`4u(P6bY>Tj-GSe#D@O-l#7Tn6+$l85Z%zV_+I<6G>bGAY_C_@SVwSb12a9pU4 z=OXm)H(T;)XpaY`Opt-c8)45;OjR6~q)e2}`;7U{#S(?8ei|5E3krccXG%h+Aw5#5 zqDQH{^dj9k%EEkgfo_9}(Z9+z&?Be6sFn3T?)f=WZ$51~PC z75^i8l2N>+&bpu1iiND^&7(jfIgflaE>8FjREm(BCEvu1Ww7lhX8m#ON=}2s@9}^V zLKErozl@3!&f_gkgaxXU6`umW*1>l_btw9;!I@7)$c8~~bjVkG8@4n3Nqlri5)v<~4ibd6wbqak>u;vOV2P<{6DE8XcIwXbC< zO)xiMB>uN=?4S`Be+do+t2-J#e=%*cA4=9JSqn4fKX175R_#?#0dU+#Lo{d!tkU|U z384FV)z0<+?mTv3=0Iho62eJJ-C?<-#(ZuTON27i$#EQi5Ial6D7PuRS+)gX6ny~2kPBtgI%l`J>JN7HcGf$P+p)Fi2d*r)x&BN-IJFC;AHTt48E$WP-eKSyR` zS&nIp??I`{C3NnnSzt5f`=bKX_ zpXZgG6Ow?Eq#ck%thP9K8oS$xB27%KSXAPS zGsF#;Rs^eV*-tGP6xnDc+5i>Fk>G$ta>U4!p&k^KE8Mb$=aeMpQy$q1I_N7wgltXcmr%-X%2WXdfj#TFGBZMV3TOA`4#Tv=O27E^pk>I^ZhImhDdgdf`{jqc*wN1y3&n2!*n1T+n zKVc3Y%bQU8NSpQ#D;M7$G)XVbN(1+yQ8dZi@Oyy}^UtRL*-bTV5N=pH2}VuWRfPC) z5&12XZ}(rlE}_wWPi^P`JbV@r)$dmvYA%Ieeodw00G$ttdI%7u~`S zD$c26`Y^ltt(RwyJb1h}a$ul2R=VZrJ3UMHKxOfOZY@A{yty9bu!jcidyUgA>F!r^ zPXh(g8||df5(pECI%D8abzsEfV6>qSuaU>KM0fxWG)e8HNPuIrqoA5%So6JK9_3|s z&>tn!poNvVej<(z5pcp25aK97w)yb>FKO(yiHbZ5g*edI1_#CF zH|YyGsxP=7$(HQf&N-JidpSeEPSa91V{M|+Ct7TS*S0;&Iw zHtCliOT~LY$e{>M{d`($2&aIlZ#(2EJD&!5Ze#`UWnc=A*xS-}6N}B}dt`{ULOc(e zfzmcQ4alirp8sX*d6G@Qb!n*X3y$oBi#J7dLND*j4!R$xj(2+Y5vwd@ zK&c$$u}Tua1BGW8$%krbWlq^K5&fkERhdiNb|Y1ZZHi6tTUbIkJ@u$a5?KeW7dD8| z4}s;YOGzN0y1>SAfUfeKM`Q(sSJXuwpFAD3r|YJu)33a0Baqj2V}~LIQCtDN@C+g* zH3~K?pJ(Jv1>WKrMM;t6+DXx;M556BAD?84yZ_2s>-6Ln@g#K6Xc+l}qIePNS|b89 zVYWJgwc2R9(x{VRItEB{sap&~56Y0NAI;4DrWFlEWB{Uo{1*Wyj^sf9HqMXwFUwK{ zvXsy;iBGgRYB_T+uitGv>UjSwOEc#3GWb##!VCdbFjq6%WQ8vDb1BWuC=!9|ULiZ> zun*g{1Y+6H{VJcu=01hJYKMbD`~_vep;AlTv9|*GBuEE*?5_+3N8Y{nSRdhc5wnU- zfLiL{>}iSNB%Et>u|zD(wthD2Y(@fE4&$ST*8`B-Oi_M!!|^78brFxn1z!^eEm@oK zhsg%F<&_w?Kk1djrq(bkzvdmDMB)BNatK!rkK0g@7XE_{xt~Xp|3h^*;aKqNw5m8? zi3>*@RvBM)*L%9^YBy7c1M++pKa{1MSeTTog{f=!SW1vSZL&Cnz0`m#FTq!=J&=e@ zBjw501{3Qx6=py^`nfQoLqy$t${Gd&BYSc5+Y0NIHSDpycvDd0``IR|@#gome+;#^ z=W~gmboJ9O
`Dnt7}5tL4yRmsAfNTB+H{#BrX{@`z4xi7xt!&5fhR7}Bq=g1UV5{cr4a_wCGKu&%u39?lVUv{b-qRO2hOi8{2(WFMjaMPX8UEhB`kD0l4X z-hbnz9(8%v-1MRETf% zEye4{cWy!7S8`rnqa;c z=NtN+<1Iuq3j6Z9o0cJsq~{xWbXZ0=Qe z2E)v*@J2=?nz1$j?Y<{YZ_E;)eBZ_r;?i;5q}g?cu0RRv_vR_^F&2M?2D-~bR#Nxf6p{{baaVsgg15M5t z*7cY)X+)>7FjiX}T!H}Iva|U$s}lCgb31flp>P#F7~z|f2&&v) zlmq%i^&)p$VhEAdNgve1Tp{Y(bZNM#yHIdx)hZ$zyt@e0Ak$q|$w3oV3Fv_#fT)jZ2B(LvMp@Q1)dbHwi}MU^m^FsuFg%VUqZ0cV<@ z#{%GPea0idFcrRx0@3+q>)RALk3}b)g(Pf)G_Jpm!M_`dKtX@08g}tHD58!Hp(Yu% zC*<3II&d5g5uvmP1Bb)=7;fe%b`eJb7}umTc2~lak88Qn0IQB+{Fkh5ls?wuA^hdQ zEP9w3$^Kq%5TE}n37#92-P}G<3j7&XB)LGGaU*XD zz}J%a46*~1Kmlq?Q?`mb@S0F4!b9in?*~O51byCZ)#JQgPp;+NaQDU`VJ8eYQcCZ) zBr_#Df0%`DnwD`*U!f`$KZHgNIUEnSLO#J@t@@BNxEzR&#ulerfGltm6;8|%mMNdI zhgulC1SSpsI=J9LL9 zM>;~zMMug&diS`sxOJsD)z8(oU#qj86rhl?Y2@4s?|M9DBM3!uFHABrMZWS?g9ViL znF?=>QS$rl2nllw=La<4e`#k)6<=MFE8qaZ5{dp}*NtPUt**==cc;LukP#Vh|JR5s7e=Ff zf9H4!*s59JwCe35UacDvSNaNq9{PsP>N(v;@@ zv7{zTD)?RNdtqDtXSL6X%gETk_TE&W)f0AyWufD#+l_Z7>n4>xwEn$o-so>mVuD+j zTDdgP2bL8C!Y&H>ce}fg(5oXM;Rd8PB@N6gb^|a7&^*OCgH|> zz&F8Ud{G6+#Rq8+^A8O)Nc<_-4K|Aqa*B{>Y;TNEg_2(cqI2|AxxptaafS&%u+LDe zC3RU+?j?;u-Del5NC7(a_%CB+Z?tG;q^<1_3H5t;Q-|z6w{4P-AF;jH4zj02M+hsM z6!LhC!|F~2$T>UmWUgMT+Fki;BuBGCr#t;S{+Wvkg5O)_NB9N!Ntw2dPtCNz@KArJ zXiwM3%5n~$%OASHqbKZ368xwXRWR7DVf6YZGzZIi;jqI#FqLx2;%3Purg_hHaK`AG zzTBoIp-ibX@^%&s^*cNr<=Y9K0t_^AOy;$>cVAwYxZistK7%kqe%&Cy%kDOY7rBut zC(`Sv6ZD1GNq_Hsc_K#-xOL?$^1MziDSG4#bLlwU6FT-wXLC34>xq;3cT&N&qqJFq zEUL{FrJnnBrm(Pq!>8y_UA(}#PEiEvG^B_iHsGHxIIkI6=HJtYf&|}hZ1w#Ln0eVS znq^45A_Z(CB>$d&){)j8%KXegpKkjHD4T#W{S2N$-nKHC&<-3E@5c~)j^Cev>2Zo9 zQ#$l;@?e1xomn*E3=vXVyuOCZYs(e5%i2jozfryQuB(b%%s;|)CP{s0N=GQA34lHy z^Z-o%eNBE)k8QGcS%>NL_n~V-gi(;A+(?Dl7on!cR&|f7!4Fly4<>#`%PH>|yZX8Z z!LVR(fRhX|9AW4WH@NI+SvUSVZtH`>`w2BuyH>w(FAuA41^bEhncfPg4OgqP)4p#` zFDH5v`;xtnpn3?A9FidpKL=*FBQ1{3O^W{>L4Y89^7n3uJ*CQ|arICUvL7r?weuCL z4*1frkI(v0Lhp^u+ivY=8j0>vm|IJvTs-S2eetIX8US%L{X=)3NZC}TKOnv{c9Z&p zB4taFcrIrCX-7Y_czFr3eJz|x{v7&9bTW4^B932d+0+ju+g|rWP%f!U`R#2(VRz!~ zFZSqf&Fo)eMy|1=VM27>@nrc&uppt0@RW_MSi3>#Hqd5)2)&V9zUk~`IpU*1R&G19 zkEco1P10xEpdF1=Mu-xfQ+e~dslLIcAN;+{Htk07NAPu*=j)PfEf9}Uhe8|=4H9?N z%pTUNbd8y19I+P_tk&Rhr12xTvJ{6dldXk9iPpbrYg#{?%NucszP_;Tm4r^uiXJ^t zyX3wO{MFhUCPS+ll>5m9EY2p}a>pNZ#83qO7Uvj9@1P3C7Z@NncJSB(p#Ve2ih z7IP+iWz<#Cmd|%L64y?7)=5ipnNjK--*LUyatf7M+=pY9+o>kk*G6*Vc1s4mc$9Ha zjXt-B(2a@{Zk(ENX>+Mf>dT(0cO~*bVyKIr(dW>A`HQ4L6hhxl*yHwv5Cd_=FzDTw zaSR7C`1(f4MBIMRl?9_@MI=b~q5r&oW!LtIg2UUk2NvW8+$(TI8YInLt&Au>vuMgO zdX9L64vzL@EmB(iKt#zSwY24q=c>S0FKY|+?@kJ5g2kAw1Mqod@AwCOo6X|eFKx(H zRg>f#=^Gtqu4rjW5wI!Y# zgk?L|e*4h(_476Gn=^!7g_;~ecuktBzsj|Z#Ec7eP>rUT06bo({t zLU)k(J^lB5D~#=9LIUF#p?G!k{hdHc)Y9+huQTnQmeHT>^vH?r9=8BidL|)*BWe6H zMk->$WP9I!EU$haJcq*VRV$tlkIUE`Bj=arMeqV!>O1XI2LfVJ9tMGQZa@M`0-sBl*j#}?P3Jd)RZMQP90hlx-y1D5Mplid46Jx zgK5~wFNn@cbK&Wr(`NE^Th)U4?Fo+{G=rrkBf1E;zyt*w>btT!g6EF6nK6-yA$Xg$ zu6=T(+`|+3*c7Gg$&q@>Vun&yLl6Hth}ZwCI7f=c$Wp!Du}5Fze&M_=b*V03soi}l zAFbWbj>4DE@Hz`gmhzC}2FGYH&|jWCGsDQ}UfL3Mo@R&Z47Lkjc6A_5jJNA#QYicU zErh2xi4@t+`2+_Tqp8G~7sT!-4Pw&HRV%I5!cnrNVxy2p(^jam^Y$Woi#b`!_yWzC zhluTBoj&Ki$E2J0Bf>i&voLy~SE>h>qbuZbF@d5#hN=J+_eXTcF=D~g= zJ3U#nAE0O2XNfrEf^AX&jT0XO2kLSW!%gCb{ZDG7AU)A-M2RPo32U_t7j=yr82Q*( z^^ai4hMs_GMe+7<{JZbhbVs3c27@Q1T_N0e+S{r*N!Bu;vgUjueEje2WqSWLTwfU1 zsK23ZxiDnPWa-tDRZs0bk&GJ%gsFLlG4TwtL4t}IRO3w@6Z%M-)WwC{3UYWCk>IcR z|Lk}kf9$dD;m=6!noT`Cs>bG{u%MPnQ8=& z&3R$#ZL?|B%#_hSUJ9UF!-=uJ^=+qNA?fIi-7*Xk`DouJBe{0pd0Qg6&h_dNJ8F~v zEqmgT^$mM(oZcmR6zgXsgICnwPoq^6X^m_*b`pg|jC5Te4c?;k7jeFXFSj4v7>KMq zo1^Lc{3!8sS>n}KT$dCRBdNbQST+mYy00NU5p1pIf6C+ix9-yXZH*!cU9Z{5EHKxG z1U&cSZOh~{6ZtP{ObmBi=AZMl15FrWK_ZhU=!GlYT2&X{9AVQo#YKJ^8GlZ2aRYm` z*Lo6Zw@o!VjG&7t50ztzV$(NCrsn z`+~@l^;=O5M4;CWW_<$6khB$iT7lkuzQ5*CI!W`Cq1KIwwdo^g#zZi)k6n)nY+Ear zTt-)I**n908D16BKMVz0C)%-W#{SrJE6V#>mbJF-aQPgXoaN^sMYQ6mQS5NejhZ$I z3N7jTT(XypQB@}`#^X~u9~|)wi={bEPPoY$poUof6kQt9aF(caH;)}mpk3WvzfSc= zK9I}Q#{BOf+=CRW6xY?>Wjct!HY+kQb3z07^}^w*jnc7DV-gzh$bsb-XXRL`4a@Zl z8C|dxB(?gN)zV+@UTMr=ofDZA<86cpU_Fd1w-K^@+9DGYnbRa`ph@}2P(H9TZdg$Z zGEp9+*DKa$G!wsM)BStqJVs-7{*l=jan?k&D34vW5bW^@YfLft9TXDDCE=QSvG)YE z!;k)`-Rl@^#vAMdmjmAAm#(pGWKU5f)&1GI;Nj%%^;I!PPyN4}i|}qJ@KW3bS4DM> zqK`q$?Hx~!NcSsv3)PLqR=vF-pk_6G@x~7~|)0C}e>M$y3_`S2atw+RpJT(?6 zPCV*NlU68wKvz0#BJ<;&%X`1Rf&L_-(RdxfT@$veI zXc6`+QpCW2=YJyiV)*A`;o6qyzM0$IVv$R=J*B!{68TUxR*SN9!`mDH%-MPMdC6pNnyQi*i_U%?8Tu zwT>dCob}OxIGr{kQkp;CJ6=M}%M0*BxT9y_X`Ccv)krg$$b3n=UJWL0aSY4T4~*&e~$;hkHPA?r;z z0STs^rSw!~E&MPjquQ_8j$a-|b=_b^cPHGNPvcVp3uigUtA;#X4x7Q>?Ed@M@V|yU zh)2-(1JH%E&S*CnO}}?uitF;?H`!#()Ix+%;7ZkBD>j?ULi>D@F+K%-8?pfTx zy3xT@41UU5bfkWPx-FYyBg3x8**ErLAF{g9xrva&K>6QJzGOyHbzS{2cM~TggMy=C z6rq|yrsSaVFzGVF@hlh4??W-e7iWT$dTeyF2=ZTuyCR4k+uLN=V36#pli9K%+SB&? z+Hhnsw+;Cyd6X4w*k}F8H0S4)ndsF-iu}ZLM`6g<2lBT!6X4XF(PjYy?+r};H}5lK zUMIGzeIAiapSp3!wgN(=A;}SDzB59as&5s<5e7yCP-#zTXC#cuf1TiWyj;xq;MGi) zlS|Uj{Xx|gJt1Y%lksE%CsfLKf0qc}V(vqCAt$Mhyv<>x@ro}pbAe@npfz>W+m4bl zvzhuT-pMSAQ@n<5MT&-R1`CArcOW%7sEhuXmlao5w=e6Bgrkmg7t`{?y2zfKIQah~ ziHZ)dbPbcMG$kCX{V;yT>kb}Pd1d>dj9d6oJgZ^$ggK_$hse>X1_ow?rqv}(sLu`R z%nW|M5=y>FCH?h@)=|*a?572+8Ak6NOLBmu8b^}eH_M^9GQle(!m&##zUC}Jmxb+> z!rdwtL>)xq7l7K!MEw_4=7`dSjmBIE3cJfIl{h!MDM=;I_VPY3Q|3?qorjKMm!WiH znAtQSQ?LNObXwn{KM}OK%Lc3nh2g`-f}z zdYTEJi;`g(VH*fy!V8i#D-98D4kJ>zdb;Q_eI`it$RZ3H4G;JZ_;&p1H_;_pB#$f@ zIEbnYN%t(ck0KIk4)+T)faGOzFdII6xNejby@*5x^*OP*HZg8tk%XvFdb=Bd12D8eC;r^Fg28qgAaHIkM-gWrAw;gZwmkc3uWMap# zB!o5puyol!0=HM&`679-{DE{$2nYN5mp+P+@w5HpvbQ_Ze#5A{7SRx5zD0&ZSP>{; zH-hgnS3!n*V^exF0ROOfV`4bW=n8jHt?{T^$y+E?Lx~VdD*NkpxlkpX zzD*Dx3*gp9e01m>BIqKqz+LCAzq(vYBkC46@$$XskPLlDY0=3zN z@rp|oJ`}>}6StfJ3OOYgph(!<8oXnQ~DNd$vxR) z;Fo>bX9($6oAu??qg^g=3}GN06QCY$a29%QE9=IY5%D9@guU$(bvf9!J~+JzMc}up zuts^r!N6yU6Snt(t5=s{<*Iq?e*MtDqxpA0weAG+H1FIGl}zb#@)$@rlsTpl*4HWe za$VAJR_)>&=qF((Su!R$LYN>%^4`neicX>=5U6*cIHgSFJ^oA29@}S{xqKWsK9MDV$@ zxj~7l@Gm6upCRJ?>h3jUk?{?RIq6B{CGNEfs%Z>$xRd~?tq`syj(pu#q!Si-4_OTI zDSd<;S6wKZw3j^WU7WYo!;v#sCx08dUF_s05vU&1%|9Bipl<~Ery+4+P#p5#z}ft< z!kw29Kg=1SmxtQkAUO0Cd?Vw(S+i)a2hS7iY3*BqMctUhuMor)r=x!fUovQB|81!Q zbxgcBG|0CVrr{W!M|=`Hxju(aj%|eQJ)Xt+3ty_$!swW$~To+4JJh-#K(bAg9fsgt z-ShD`d@eV^tjM6AJg--_$N3z`$RLwpUaA;XO56pfoyW~l5rJvB3#LDWvmR}+1Q7yE zovtX6zxDvZ`>I0H%mtM&HC3UJMzhLO7}Mgr+fCTXhFw=q&;NZzynk4sHralo4YwU|{}OjPTx>MMgC?!v`1_BdXk0Ws}tff1h-g+JvnGF=7C8 zV-K4C>-110bK}tH4aID3MwbH8GplC?!kTiZ`=qzN)v1eU#u8ui!bsFxMldgxXLAbaX+{$oCBqA;t7%@N`9=4d~a#5)3M)-dV5WN;l@RI%gjK6eYjZ$IcEv>Mu=^( z#2mA+p6~Z3G$cJWGZrUZxxr_pH$xW9V|l`|^DJRGpy(|H~_+f0s25+rA%-U%sfTLi^&tV>@_0@isDOGERzZ`_$mq2+jlE znU#rXgwY-^7iWx+&x6Uh%0f(5e5O0G^86fKoi=!g=@$vlnA2-QwdMqGoR!pKD?3W} z-qt70M_P+4zsDHgGXu(e@DhSYOINDdvBLSdO0=i$bayRteWYy;Mbnhw6^Cyx z92RRlwSMC}KU`7zYJOjx()N#3P<-wHE?x6+%>%_)0{cneNN`R58tTKrPZe1+1#L#xdMz8&r=3`5;DGYzuJv57lfaS@G<(E~?*D!Bd*J2( zBjnzcZJxC?g|sH82lYl}Bw{Doq^Zj>yc+5NJD%wr1LX9Y7+achbkB`ZHu9170qMKX zQW$;a?*&DiB~!`-(2ChJ62GUGZL00*_U@D+*1A0gsc(T0oOsS<54 zZ8Vx;|IIt*%H-jAsP%u!2qcicjV84Hm$a@p#bePTzHkQ;#)&eL;XXA$gtq;h=RRds-r znS|KNivuLf4AE2v0@v~2zK#)!%wD>;?p*iTjIQcWQz_R-CknfEr;g#Nl&Eoa;zJ$V z9VJ_~_qkcKRmG1;Mp6zsze3~IU`(v<8f)B@7hs6B-G_I!RcLPVkFoImSTt~1mWm%@ z;8-AWGpe!dsNB^0L!zs)pi6@ixsMB3BWJZwMy+M-zJgCNOasn=1CD{#r}r6NNs?D% zF@?*B@ECYD)-qL5J6lROx8ZsOOQRh#Neo*O-CE;a5R|eOe_eh_l^ib0s69c2dR?n{ z#$=n%-DO0nGMl;=XQ>)0)K*}D?8HjM zAY3<>U>yy&CH)ekq63uebi)=z5Yc%n5E9rZQ}!n*#9>Yjol^L!89Z;6oobC$)dBV6 z5f;me;{yeZ{iOqQuZKhCB9pt&4lU*=tw=O2jJ&l*MK!}*JE&D^d@~&kr;AZq(6O7R zT*&{7FBRz##$)RDf}h$ku2x)$Swc;I!4D+DdHMt1cs_`*Lqo*i?v5n`{Z8O*b+P{$ zC*lusYrvKpQ9B^MNtZ%~OQWQG3(tf#VxFX$o93zx(`fdMFg(4-k_v7%C0}Cuu$Cxd z;|h!?oWS|bKN>WlimWEpFN&}MgH9{142$xs5YdnUubz&hiHv;i)gxxU#;Wn7EKjZBCENwZ2CoVo!wcFr)SIdb6EgJviV(!TW++O%awp$YJbsT^hE(lcF`<&z9;ofvDdq6()Gl77AN#a1lrU)UK_xzwb-e># zX1ButhhN=SdIKtW-_cWcZ{E>{dTOh?>DLUj4~>}!Qi=y|i0R!{sUO%vuG;3?zC!N` z$a`k_)zTfMSPdnTT2cN|>6m+~VjJ(tkW>L0fQ`|oBXYWmMT(&A4dZYNeXv3I;0T)$ z4Lvv6`s6&H%Or!&T_^n5y}pypS(ou}V`KN33k7*RK&Zz~)=9~g)O+h&EGU^8zez_< z*=z)QWPM!HBqv!@bGNrzQz0UNv?_nN<6Td7-Q1%@pGs-d< zJvGot{#8SM=6u%go zJS|GJe#@K3&IpgRE@=&l-XjQ+eNJb-t9SS%f1-VN0%S$+P;7*v8s0N87VhjPrjO2JtziFhLO==YzEVwi^GHU0 zzT^@;5WOAC!5hS3taEFdTYrwE66$_cFo@(1A*#z1dSa}rG{NfS2|6vEbGWRW^p5a1 z0RrL8l%baVI>}J=YLr0OLNxRP2rGBRdtb*IwDMZ};*)6PQ!|cHSEpsyat<4zBASOX zH0E`o!AK}u;ZSqK-#69ej4k=x;NwYkFAstcvT$5v<>Ff<-&$#$H|HwrkxLHBS zB<`2MD}ttPK`JeexM0zdAzdW--RFGm4%v)&iCtgD`0)dLLL3^u2WNA{Q>7ug zF5k>Xxc95kF$`WJVOK)ZLQx)9$d1d3JG8vF8_{xZ^*-x@gbgN>+EYo^XLm!I?OstS zE{Wfo99zGU+1SLavT0Us%w<#8AV)?-&OH{x)`cAo;0gbo&?F6S04$fqakP`flA(AKf@VbZqKqzL*Mqy0nwWv z2T~i8Fd9b041wRXlT_|m?9QkkGa^x{%@gVq?8ZJE3*UAY>!56cwTx8ywr})j@Dhr~ z9H9i@i*sLTofswU!+^tL+~h}JmYzGTIhO8%L=Ld}O{Wq4*t{s7&kK590DoUm$pPJn zeePYf9)HHjA#ofLCY&_w8jD8YmeC$FhP8se>)Rp3R=Tw+P{x=bZ{h>F3M(OC5PVbxg zMp8l=?xtkPlhsUjo067v+i=V3*j__}J~g*G*x5wb6i4?S^$^n|#>f+Rg%*OAtf_DL zgz&irT(dSOj#1z_uL?mkqhr?YD%F&Y3vp4Tqf`V=hl?`>oNI5zqb2`O06ZJRlU18ssJ4h)A+pd{+RBsz)jlwe^wMvThB32xyxP?og_mjnk*<=hECOTe{ZHOxo2 zbKu(~MvMW(X+D-sic@q!@;c1dw?q_QP6?L7Cz38Ne{sKx-L7AR%*Qt1~EK;mn^ZmNpv6Y=f2Xm zPmCC4Kr5f*zXwG8?f+WvOcJ1PV%q(qKFAUiU&i#9O+a5+286W;VcG11x)4ZyrxpNZ^eia5l8q4 zzbVPOe^hu_?fUJ&hW4uU*MMviGFdpv2lz>Ro?whYiG946Uzc6{eV6UC(S6=|0Lu!K ziP3zPe;yiVKg1YDc$N=jm$S<}`uj?=5vEZ2U@|p=f8|3ol$)CvF+$@RZpj{LP{NyBICQy;r0|pQRAjuoJ zm8k>!<`^*|L+}Sa%04Ko_}hZ-WI`Ft4XJRjVB4k3V+Y*8H<&+=?~M^7BKr6#pWy^l z!04YakMWfk@Ez8Ee!BsoJXQcahi`K6z`i*~jL_)dJA5^Jd~w;tUlwrPux`kTAU_2V zd@gwjV7_oMU*kpPSFSN)xNwRuay#8nR`EBYWIP=-7J&51seFz%mnA)2j2Pb7#lP{# zij4VlLb4I^+YMM2@t0mZk`M7gpVam-Vhl8%;G+Y6!ml@MuU^0kUQ})eC{wUE%vV?Q zWcqFy4cPc2lo@!x{Ta&kRij+>^!VE_Oa97#k$ zRL>TUQFe548@FbU_^KH34=th5Edci+;bnmN`lZ~;ODdz)V+>QA;8t$W9&J}49Wj!H z9$f)I*)0Hxg-LvZw^LVcZek2mY~~Z(WtpTE1f3EH=>zaGAWEJIC@M@Q>UjqrXHvPj zi81UT_#2;K6I7V+>!8o_;SmQAB;u8(0uo%$7g<$qZek28oaZ*aRv{68GJ@Uo%IZLV zQ-BIl01_h$`5ZU-oOK){Mlo@KFY${qhy3VaPtx_rg*pcCL9YO$tO4)m{qcUm7z2Pu z_!R%h{#jw-pJhq40lfYW#eak|M#=}1ofI$Qb1W`5H!+4Zy7(V%x8U|@v!hZ8`?*z5fQCn_y@7)0unSanJ#ab=lC6PK`FI#v#yTlAYh9$fxB!zzV@&ubX#Bn;QVm#Bikjq%g zh)8YG%dzYq5=|=*H@785VF&ND3`%;u-dFlom3KU@|jU#>Gqz{Uuu1 zY*J_?Lj#kU!E!E&Kp_XgLwvS^P5&SiMFCXc1i(gaRp2^4%O&~IIM1E@gmtt61_>a8 z02-Lft9T(O2d4LmSNSIwE%}snWbS=8rz@0qINzzyuXw$r$uZN(} z!9Co;HqI$m1+~oJ#oWZS5ESm^4z|;d>b$C91}_RlK|&k1^G%MypcH;jxc%m~hP@lkG$o<(pE|IY6@N5UMT5?I}~hiBQ&y?mAz2GD5ZF@D8^oFPF{YG5Pu zxY^EQ{FX;(#vqXs;G{y$8-4tq@3RL;D086%(8Aq3%^P?ZV*`13lHc-p_M1R(U3KBMR zD}SKZqA;kT4B{!zLi%yOVn=@m<9qoaPw+7oMtOEC@8e+%lG?Zl19+T&=Igx7lfr2p z;MY7!8<5P6no)3u2e^Z$X(LHW8n?ny>g9I6Lz^jNMs8b}jW+(16MV{NmUMxQ9DhdIw+N84Qo^nMAOGPw_FX_oTLyFR)gjARlm@K(LWd^D(Xq z=BJC_@Kv@usSFLcTR7`3P!&K$#Q%qd$$A$*V*~%j%fs5M`?!UNa@|$eQ4Oj%$j2GQ zRhniy%HOzy4Rn&oD9d(j^JM%|pVR()i&JU*s7Qj^I-(4^8%b`UJ=L8ckg8LE+z7>+85R zNOFv?G17xV4tw}IzZ`UKKR+mPoC+?I!=P~r=Em>Myo)FJ0JDRi>EO#eL{bquK?ZW; zcy>0OWItbEF4G;)cky|CL)tasg+~|v&hO|WNm81*tMv-%f{*b%&YK)tWivUUvFT*^ z9SeA?hKH~5XZkfN%;G1=7U|*}CWWMwa@tTR`BUiRhpaW1jcVxjdH(sZpXqQuOk3c4>G}Yhk@L{32*IHT07rVR|pZ@U7xr?r}UJ7{v-! zF~-p?6KvwQn*3Mya<}O{v8)9c%}Q1?)@;u#Zw4v0aEDh0t$loyw`IkDkaT*x!9v5& z%G2wRL1G0+cEB^djW==&7kHOF$$xR)F?L_izp?~_J$#2hkjV*wL5Bb1<&KD>6pN@< zPEgC3f}FJ?YIKUlz+`&_d z3#m2*A|de*9x3KaYYc`-LH;8CwK&bM>2Q3B=kP8r#NYrwM}A$dM@e48Nf( z=L4}Qyfa7P{#^YGAkAO6-koZ?hd=Q()}l}uR0t`j3G}^7ze{k?!DPCP@ADddm5trj z;U33~F~gO7kLNO)QC!3~d9l)-2@Z0ni^dX0nx!&in8*!`1k-9rgc?JZ&?r%uz>B4< zXTcW4S$;>iV!<@#q=U*V#v;fdn8rK!C10i~BCj(TWBMJY@>YJz7fcmmgzAQCc~((k zg6H!p>ACB9ky1XvVII}+Fxs5)P2>&yh)+2bKrkQE8D+kIBCq2IeA28%7<6zSJ(7S! z6K|9JE#vrqj6;|Ui`m7+M`)HPbn<|M!bsk#p&+-DLg73=<5m2y%&ENx3~G;YL-^bq zG%!EoId12b{D2nc{hd5z8rs%KeFax2C025kGE+>jn=KA1V_AS;4)5px_!<{eOCm=x zTt^zqd|)>3=I7kXMbw~*zaO164{=VK_Zl?va>dVI%|Fqke8_&DbQwjXnTucsZ{t7t zEUT!Mcu0hr(Pt@4=dJvRPqQ-8sjmCkN;3Br16bua24X3fpsp$$Vw-e-A6w0dlVL9R ztz?aZ!o@kaXx552WUcWDp5Yz5$J*mR#Kd1)p{Of86&(o}g6*Arh9rt>r5INPmt_lJh*p)k>K;S&B%ztlG8*~pc-`x|M+%3EguDK2$UxRk$3o&FC`5J_b|(0ItqzUx%Lxw%>K!9{7Bi`r zekI6oEZ`g|dH!#$spr$`LBbDkmQ#*MjOm)ai}g&C;sFg3oa1bc!f8p-BruraK_NSq zP2i>zANlILUh(ApYDZ))KUgNl` zR>mEG-T(!VeN%Ds2zYhR9?m%K)iO>~ZVc6QV($?!Xr?zXPe7q!5%&4!>~0$gW3^vm zOwQ8Dev!VM7r&Wa)AeV~TE}U2K2|dt!6+^||H8Yj{C?x(ZR!7ExQ+Z4bUpy}Y|$a8 z03;oE>Y;C5e%ELJ-=E%a@03Hi3O8z+C1s8z)(9o&&y71_-}u;L5b0u_MFcOt^JB(=4}>}zPDUus`4Xw(E&CuzVkU9;b@pH}l;EB(kL zd4>_1Z<%B)7xi8H=<|LwiMt9ETNYNIa%2cU-5 z6b3H}t>!caEGJ;CrmR7=V|Ao2=L3)_WKpAgo49WC3vXNen|n@A;X~_iqx$5d*C{~O?f1p;k19%}=5dno|RqRM`~ zOxAr5^dU$}?Uix1!cl(1PuMENc7~zwTd;#NM9R0oFhRB(T}^-M!~gs4doO!gd49m93cDesY&zb+Y{Xg#FmAskd>Hw5>G)$GUq^3v0 z(e(@C(EIr$^@>m!2(4+K#ukjMfYpY`Um!am^R)ofs6ajHa>=`_68h;$+NDq|e@Cqu z25fI*F)1|jSANWsmeDq(ySOfuzq-07-S=Mw7-q<}(0{-&zRT~qi8ry_ZlOM@TsB~M zR0w*8oezZ_KdzMJSPbA&qDGMO1||z^fT8@t>Xj>h?Z&N~33%w^4zIC=|o%FWV;GPRAyy=);Ph z5G3i5@-SFP)1|bDFx8(-A)5nv601+I$_jr^vFb7iAn)RpE1lO~07J?JG;AO#f$#Bq zZscZG_13hS%VAb~R%=6TR62#upPhLGyl7SDvgKIHCY@UvlhIB(mkl5*SYtw@2)7q8 z^NjT#o$QXA>R%h8|bG0)UhBD8gtk zKExPMs2Wp!0DcOffi`6#bBZ390_fqCGO-fW<&K0LLoHRz=HdgF+`009t2gZH8^KYg z4jPCpYdyp;s!*qd_tHyV<8H zb&y`g=~vI(5M0ZLi{u0Qc{~4%4o%F+p1hHJ?ij7Eh{iTfI+VzCX$o+4f7Oo5?pSdL zJC3ENFax3;|1tPsueHiUw==vy_gAn2aOZ;>R0)n7dnnVc-=p)Tm1CP|M9Y?7hP5rP895oH~G(=t-rl7g?XHkeka(*F~|6RLfZHY z*?o^w2CN0clv+ZaIZ4SXnhrKNax=juI&wP!1cDltkTB0=F&^G>b}^$FJN%4(9lV10 zr7J0=XgCZzE5NSwBIrM{>5@(2#|N$*{|YYiUkI=hg87@ieiXt40t1X-4NpqH6P)1@ zhXQ!qOle|KHdv+E_*@7Vg<>HSIHdR}p5&Fvj{Z|Tlbccqppm8NC42ZQzh^yNa7D>^ z90X7W-?j+cB|`ZCoQr_NPWbJH$W~u)?(ow;e)Vrz!%bYvY<(<^oXn%JNI;94zYXvl z{*Pv9gzx81JWn$5kMJk@q*!~wXr2?Y*778vkZ1<0CSI7#MI4sa9N++V@m8r;_p^^A z*%J>Yu&8b2+7*A8b~pPi_rx$4epxm%+N<*KP(A=XhFP$%T6GY-8ru0g4>6bPxRKR5 zTfix@`ER5#LOeg8%lM1*T#`L}hYvFYfK&X49nwmHfnbe0AHF9CvH<7PV&R@j@k!(8~SCJ>>1nFzZb*y;Nj zw&I^ft}WB-Vi*6zYF^5<%vIN{RN>Fp0}wWpxP}{foL0qLe~^BWDZb3glbrbqFfNc4v| z)b)OKnAU#0@D@OTCK-OPU97b7cOGOqSMd@qXR_1fUC3r2HI&%jmwpk~^DD<&x3QgS z`p76*bPV7cP3luU2Z_*1D5xG;72-FIsO6Px;iM7`(7|2YOEn`|)Ujm8(t8%%RloTc z=f={+{Jh(B7{IywrQ77q?j-iN7IocZh3_ipc z*{4h#ocsSkZ~?cNzJc(Z*!uya*1;O_E#JMr5-_-tQ~beHTTM*wTX1;cKjz;v{V|Si zOpl?O8F_FqY@jaK1j=f#0?;oY#Bf1U1Lt^>CrAI_Bg=MQwdwiWmh2pJ!jZ8j3e{){ zt5)+#zR5P1y0HSp1hAA_SQWW$h1fCClwTchrHY3b-*e&N`Hx@t;OxgZ_*m~48dy;f zA4435q~Udm6||>&#G$}NhOGuNXHPwJ`9tD6XBX|cYU@?ImTwz3GLOD1a;rIEGqkVa zOZ!B+jt$T3JlN@=Vca*tc8H;`h z*y)fgAAqkV5G@pV7%%=sgFS!Ew48YCrH`Hbe%HJ`R~XMtt!7gAsdthI%GjA*o}QIm%i3sbM@*na0@28E`Wi9vUNcn+z?EO++<13f-J!GmrB)+c}eO2aOR55fp%* z`4B6B2#}g8$r$FdhD%t@h9c55vx}#Bn)Mu$T_v&T4+Mf+IUeC8hMNl$0nzfon#&ZGln9E#dGnV>_w+s5{kuFWcXxMp5B3HP5C(Vm;O;?!JIr7~2X_hncfP;s zRGo|O{#=}bVyK4Q)4luI>shkro3f$|HYO=12n52G{rpK41VRLUMFgS$d+{TctO8!B z%|5FtfIz-OJlH{?17i?KFckzMa?Wg15eEK%YAPr53H1E$>yUWv zD{utOT~^cs|D}^;$-LKWnt%J;p9-~;1T5D z73Ab%VrLg*XIDB~Ui^OzaBwoWvGn=h2Z&ag*#QF>{-1Ynw{fs=b2o8t{@=&gw169c zTLQ^`l2G@C9Y4X7-pfM%V3d>cAz9(TS3+f^X|0k6+V<47Cf(z&ZsLv%sYnu%gc6bu zac}unzGbciEVm}7L6@^A(z0y?rP}z%D=TS}Gm&xOnLWK_uQfVxcjJP|KuF}jC>)Es z8Ots}_?EV|<)>w$0r!-TH%89{xylUqQ4huw?TGl8;a~x(QpA5pQosbDaByA=emRH% zA9J8c8AUu#nSnK&5d{2`%SUyDf1~8`FoVH)%KDfZAYkmK>i<9V|HISNa#`KeiaEGzw7_*afUjbbyjZAL#`5tk5EEABv3M&?|8`XZczA<@Ww4Z%@A8-rdDSIh@ z-br82meBLiyhF(A;=}O{p9I}jgGjp`4k95=heh2xP8r#A%ZjKp+^4VNIb;H_FC)bE zjRFL{*Iie_u?lzJk(>-o|eGMYk@7vWGCtULN(*}nd<6)9s% z;k`byej&(y+pJ$j>uFgU6*~FUUg_X4P#(0nR>6qQOi&83_YQZ2l`D{HzTr;PpU17Z z3{ZQkT|JdVS4EUg7^1Pf+juRYG3#N}&!xFUG57PyX}Rn%?e}a%R(V$tZ{~lQH+ig> z)ykmbEB1#SlX?`1A%hL|tgbxL(q!f94;3?2nz9W>%W5K8LZ0>a7rZ06^>hv;*9S}s ziaQS~Oc@zB+$2 ziaEtv94sNL6_@3e5bGwUDXrz&aiQ`Q9ijjmC!62(N2oWUheyq5x|sK{8hhI3KTh`U z-XD9QECbT}xE!1-{g^2JTD+~3pyen%W$uKBf zuW4ys8m_4?^LfgDo4GFBW>NFgrWsZPTRCXYZRtavqvKVZa|~Qlun=UOwfDx@jj^QMN2wyNIq7putznaY8>Zjd#d(3{C~Z&Vk}kSV~`u?+L;B|#R%#Qv_Ji$fxb zpdWgq#QozFHkq?He&W-vPyhTG)bC+V?~J)V-*@ALclC5lBv|*rz)rlt&G0%y>Uf!C z`PZPJ?7_EwL8C6LJ#e% zW{aBp@*}l`ygn|tG#_bYgz{9MkIV9FAz)&%heT&dS|E4^#Uf^sK66Ou9x-(Tw= zGnP{jsZLo_ER>AgZjU$^nu7-PYe<_Ys01?lSV?q%aoGE_-}f%+#&76R4@f4XEwi&A zN^Q_&o2D;&N9m!n&qyD3eb<-?YhouH%}=%cr}HrXphqX?!kdn=|2w*Qs(esSv+PPP zxtHY|NPjBaASs1)XUunu58B)Ci)xHdsvg};ex9#QQmv1^>d>)G_EPfn%7VW+P-V-# z_qi%tJ8b>CxcpfEu&}oj(7uEF04H{=DO@hoei-@vY)hfuwHBYNbaivj5-I-x=d~?? zTkSf`ixN(Xmu$`uh^L%hlM_8Z?IpzRZ;ta?X7K1L*`H)pg&Y{P-kazVYbQxnd5hG% ze=j0X<~Hq;i@3Mc<5j-yY5ex*Evt1Cl%E>g<0g4OS3>c_R0hk|!GKHWgVU3O-a>f> zB88cl0sjMzaz$PSlxy;$BXxZBm>xT>Hv}^mN_HSSWPACJ;_vZXd1O5a64HFKLyX*$ zTV0*gvF8m$sCWa4Gc`Fc-cR_m#$>X>M8I|W49Yfw@y6?Jk zM+fwL#l}0X=H8(*#~Sxk+b?DIc{^tRaEs4Avg^j5OmfBM@Q4;bv$hg4F|czWPH`wS zd9qgXS~n=y)AWG@V=qu}J=UV}`OmgawL95D9(;$5p;`^}BwFXk+w8b*J6$4kBKtHM zxV)vGU43sNq`I`0(`eSV$Y5|%IY(&igIuG8bp-hi>#VUaaqIIz_#krF`f{6zS0T@G0Ubm#5XKkl0X~@wn9w& zJCmt=c>+{4*S8E8Qq;bmF42r-qSk0vt6_b*H_zQG;;jrW^xVLTusRj z)jahUqZ|aXF3_9vjlmxNZdXD#9@lppue+Fa^K^a~w03&HW5+<|>rvR6ap-QgerShg z{nygrF?(_sS-So~wOknmB<^>p>Bsh5UsB{;Ly-jE(V3Ij4tBHI@YLjTXfk=}cpk^_ z7&_+qQ+GO6cxU6p%`e$SrHnEaZ29UqL}8QN8?uRRctL zpiuGTDdQp>8Z^_`MA``cnYL~#Qew0ri;=#_vKfm$qhN{=nKD+3X=xs}(RVW!j@Fl+A z=>7&&?BdVgQBS28xC0shtBM=a3OE!huH74tk{!s?Or}c^y6~AU&#o1d^my;YiqFnd?qW!?qq$C zd>E;Oy4XE1H3;Z9mgx#EdmcFGT|d@^P6hcQYJf@#GKhj862B(?X#G%|784t=l+knO zAYdkw8^^I}Cb`ewImjy_(tN;`+h+mr(`su78sPpDa((3WXEKhegKO)eRjZvUxw5&n z*~vom_*gz|nKN-OZF*TxkC1gWW;2A84gR%)Aoyv!NGq|Fe!BPmpmVk)H->N$R$nH6 zdqumM5wO+Z_N6*)OGhZ~ZrszleSby-D*|?A9;}dNS+zJLQ5}gjk(aZ!X^WyfX-o;C z%uILJ??ea+lPHj@S)Ua=y=6~S5}M75fIBfuo~kSIJ4DFkq0ac0BJv|7tNmDWwG^@K z@hX|<#G#!w& zKsSU)gqj8VHnRrA`it~&jQ{qFXGexea6xrYNt7VF=G>@S#_?vH1=L1Mm{$h-Xp){! zsC~+bw28J9>P*=axP(2S1){ThgrSzFT)oeBxJ|Kr`JQk|TK}}Z;48Qgb$xH3oN*SO zn*fyh&ZrI2^_)`9F;&x-`ud?8mDftw%r{l$3wee#0!PfXdidfwYdU-rFrv2Cf@?~4 zezJk0NfeU=3)+hF}-S>Qz*|Czg^{ggt)m3m*d9tFJF%6q3 zOIm|XLX;``bOAfuuS*AZ7BI04O(V37k0$eiE6Vvi?cUuaVbo>Uo6Ca%&-T2fFdx>8 zE%a;nob4?tGer+4Rdcr=8hrP_&UH}1%aC6aA+x%;EvwWDW92g4R}Zk)aY7%P}8L>=i(|DCzO(8@rDH$L>?mvd7?~V^STxB zDo|OK1u}b%ojCkB9vf2fYxIRj!OC);ToJgHpw?n`hq)w5WbPYS)}?HE3%$i6?KMH2 z0p!`p+$5hgG^Qs)HgIe`$}jt|#VVV{bs;*P2GATKbp-C*4TSHo>$35~<`n9upW9cD z=@erw_Ji{Eoid|Mv}FZ>U)EwRa_Z*~x-U{iJH&DHy_IqUsZXPH&xvkP)C zDL^6>Re5^z^J%0xbG)G}3e_{RMvmML@ql2w&Bm%*1j(u}dnKRDO~#{Ybxhy*ve_av z>&dko&N7SLc)4TAYyF_Lu6XJoVCP7eaA=X=$zPqNpL4ULE2#01ToVw+>s6S}W9EQ@ zTCU`YWB^=qpokr`UT|w<$=^<_^-x2NyFz1hW3KqRb==0lie+7Os$u2n(7?@8`z~Vl zhp$k~c!9@Ch{Nvw_?m9Lhit!HlTDkw+L7)UPwwuA1*49e3RrkoEXopxYd4^Xv45UR z^84jY;o8{@c{y1wm9foE(3xn&%{4nz^;SL5H>$9+B##Tk`H&^0Sa{5jJ)^cARve#l zEjDW$lT}ukcS_W3v^#o2AuO89h~vICorrcphDx^}{jk`O>y8S~uW{{LHY{5=bpKge zq}wQrYD#1z&3f`~GMlyMg_lE{$FnzAY#0nB-oKK5ij%8fu)d7D)*IpNgK=J?Dj15C zs$ozt*N}ONjUrcPRkhrsHRup!Z;2{Tx-=sGJassB+VC_>-@`a&jum<^5lU%H9W6JS z$1U-lHvTvjCslFpfF_OVoZd=l-B0Jt%l*RwV(B#7vkHAbH?cs*_y;EVU0UYO9>Pgz zvCm2~|#*;~1}lOT&S+hZygh0>=U&JA0)a_#Q& z(wv3`Bg_U}>7{0Ap;%)vGLb6p0|%i{>Dgt=s%eGyOLM*XQ81_21N1ukS0U0&sjwV( z@p=n=$uvg}*?G+}O`68~laBwC*{GRVP!?Ke?bUB<5_zQ`BVO!V8*DurEg@-^UJZA@ z2C-&vIyG(#W`M=d-K3q}Ms*Vfd(bR-B!uc3HQz5zclACsTsv7d9C=@>y&M4)k}3FP z{kz49PuEO$T5^5l zG@qWd-g8s4noie2C0D~8IwZYW-*f@um3Y1W>wiwFEJSzhc8WG$2@yePx;VGKVam9` zrY|24T%K@~SkCQjB;8vvTClO&&xheg?$G61ioMJM(ppZMuDT zkaKy?q7wWNdoQ=ZN3zCHr@yJrXvND$pu@RE4nVo~iPpL9$lJ!|YF$yAmgrZCBuPD% zNZh552sU2-u>NsdzyQT@^WcG#NM`#a7` z&D4D-w6)Ckj2mY~3(Kn;%B)50M8w`Pek(#M1aEI{4}uOd9Gj=ee>Kyi^@jo%v56 z(iN&uf5rCgPLnzcW2rkl#6|;>nss6H#LMBhdHBHUq)=sS;=Xon>ebH;o(HHq2;3vi zfDZ0~e;aydcCIZ=Oy+ zkAFAxu6O@@I(+|I+V5#^=FQ7t@=*zNbcFKaW2$1v0xuP74O^At2~}~_4koyiY9SAY zvLiI>H>)1O!ho~GZv5Otg;;X=VKHp=4{A=1@1N$Ei<*gcoKUbBe&FpQd4tVx?tBRe zR{wv9RG5FIwnh(xPb)k7`kR}0x(3?% zb5maWiI5Lv9|)!wm(;b6XFU})jt?}BSFMJ+(`l*jxND;3JML+`&*^;q!aM}#yCn_d zSk91kjzA>dzI8Fo{~5&Nr2QsyJ&^=Fe3>~}@T*`0`ny)HKB+#*Ofuo8OhOyqN9vf% z@EOB*t!=fPElt2rx9{={`rrqEV0E_xjmtaM}wUdsRwxtiS5FEieJs*qlDQ`n%V6^lR*wwJ_^dSQxmg4bFON9Mb_K)n21yox%+`P z^W0NA%M4WVXs^t8iY%$}mprA2uCc<^>+c{31)J$eB^2cK{2iWJnfrURy<{y+h;#p; zHYMrlW<#5o_zVT9YqVai?b)8rXlYeUe{D%ue^q}SrclUP~yEc*Elp$SVILk3Z)nbb>C4w{c*f@I=fx3Q=5r|6#wKV5-=f+2&d2^u@ zhpiMJJ24rf6!WV;FPJ$Bi;H0pqKi&Lpdlo0vC!;ERpZE9?QnbqP=K$+&Dw-lnTD-w zi|N7l0fw&MW5JC)AGPgRN^S$D|B+Zij@lc9mxKNf{HRdCtEu;EtQ-sF5)ZPVq>2&&Z1u;h<~p;(56XW z1$Q6gwbiYGpgg@Dvhp=|GB!W?_YS#C!CabEhTbAq=56VR3TXkglL8xP*i!RnATs_?28yBz(*g_zu1~bnZ%ih~#y~r^! z)biPzCKOgp@}Gf?s2ZSSH$#enHOJXq?PLBasT%Li!G1S{94~u*UNI3D3r|~LQ(AJY z;Y4ps9uj1A1-asSyNq_+ltsct-samkP=ARQfOODe6E;ZH{3c(n5(<- z6@muHq4U|(Q(>|z0^;NQgjy5pg~neYp`CTalt_$?Mn4q6HZDJ*6d#X`i}Gj~C$frQ z05>@yv_Ga`uC^D}H#Ay@iHwn)K6ELtqpr5CHR*G4bWxp)3s4dTLGVZl`^#F<{sb20KOU{w%V(!oo{>vaP|;FIknxl2v7@0uR?7Z~ZmxGX zA5e8jM^DJ8jHV99qGu%kJ=0q?;`{Iursa@!3-j>+w}+4Iho45m&ath|J{Yd*KQ7q zGzlsPrMuGHf9jsJl|YvY!MzXIdN28Jg-$9e#KwmqiN+N?n}teAY2YUAH;%8$j2_C_ zWeA}~-howwmP1i;S*0s~gK+X8hLl96e{XXsN)bU6MR3aRy4Ovj#THbl(|i^mNpTco z<5L|UfHRBMz)exMUFh3P%-zDPIzxA^kAWo_d)(QtUaVeC9Dpu47t_m!&?rB<{RZXF znos7A!g5O4jGl;z(NWCSUb;|QUa*tDxwWUOeW0(YD>o$uin%N~ay@5}f_GeWMCi2h z*}{CdtCWIe9og~&aH*q*dc{IEQpY;P`IcDfOT7^Vl@{s6^|E2kCsPFC^!x>q-{0RV zEL1S`*A9tO~5p2*1H!m~-6IwKjpK~J0n zuN0A3`5vP6YA$=4f{LU~P|VH3(bmcuSB`$X#=@rpjO*S;^2*MYb~I4iXxJKE0I8IN=EZP8l0_$_ zg*O8ypMa;0r>!S2N7%DmZTN9p`FY0N_#GY&dD@Jpbcsx4%khv!E#9Wvw`Jh67(CJ* zHA~(ld*Ozhr5V+k-We)>Z1UN`> z(e_CUzP#dAP_nE$)N7B|;sbhv{VAonD~iG*#~_if#?;s?6~Do+@^h(faNhVxq%~ ze-@6|W%r6}Co27O5kLWnw0MCYRn-S2l{KZ^ z-EwFxi;r{iq zQd(U*Gace0b^nP6FGDL~YkK%-ZV9NR*0S0S?N50jOAJnm8=#PSatBZP*I z(SZRLK6^K)h1*b!Y_p$pDjNi=Ko4oUtWRJr#m1Vn?l*ycP(VpJLUSdkt&kpCt@r5}K_)36 zd#i)e;vcS2b_1aO57#h0*14acYGM`zxUYV;a3fpxi`U|%1nT3Mvar4tXa_vM-VKpO z`N2u<9h^robL9@6&;<9$V<$^MAcSDNk$hro6rkl2TGH*7(9=n8aq}YWZ1!F13zNpl z^iQs|xZ+1xyFHwb>>ljiJex2YF_1h#`9zaVN;YsSq9b0}@`(}vNl@m($CmEt8=7tH z!S|z*(`QH9d&zhE;rqYytcTx^VoB0J;G^Z+`U++&%h6J?R;?Q8FRwu5;1}t|Ih|-K z_1NZqTs4~F z(YtYRzp!~QI5MdCE{%7R?Dl~0m;s+`w%hC`(!e%oZ|K2{L>f|L&0tLG13a|Bh{#7# zT#w}vWkr-xc7$%eKevWiIjmZ9{pywgaq68p*gQxIMG2@9mm7`eDij0gmm|9fzX0Ug zciWqpQ;a9jBGA{<)SltX>dnU=lm#~TCW7s4t(VR=4lWL6Rxj)+YzGn30meDLD{sYr zFb2=6kY)73uVV%a0Ewg}zZFQ!fH{{G3EpRGa|G&bd^HqkUh!HB%{MrJ7O?s@MoM_{ zJPb$aE27JtRaGb}{S2sz9OBqXB_;jzv{YgWocjPorHllyzQ zh7e%x_?zg^{SSF(a+^(Nv;HmbY33JX<6vgvU}tyRBW$`m{5U8QmL6QGPXACxn|?P7 zw;F_(&<#eKNswlQfJyA}2JgT&Yzg|SOMwk_U2U!PL8*+8k=-tid1S(sB2aqTW%;xi za`xpz$9u_X0DQvJe(#?rcylw$y*uW^9|@P7Z&0Ue?&@rAsXGUt%+5Dy-Re@L&3Z~f`%f&e*09t#QIQvy!($CD4h(7$jj6AEl7V*cc=ap!&W39M zv++m>6^srhHYp@4RaIds*PQ0om}ex=;KJ$`w{!&8|0ID^^q>+U0DG1G^XWTXA_}%3 z34xM;bpVH%eF>n4Djk$k3G&Ik=j21Fx?3H~ zX){<{3DDh%=XR=fpNG&%*YHiB#nNv6735Fo=RU$OM-?@J$FC;c+||5{dx#I)@($Xn z$$+NGgnt>XN|&N!Q3v&}Rj)2h*Q_obP;FJO{xOnVS8kIYcZLyy18kGhJmwwZ&5UTx zZYy^%Fi$NITGS`d6br%Q#N%Xsf7ZcpR!0k5ZNdks1b@nbLlr=y(-Ncq?Fh^nf?xk9 z%!c04bBQHK(0`|?(dBlb}++L z%M!N{kUN*muibkOjEEu9*lGxN}8CM_nVjw-&u_N^57Id`23;|@u*h88o5`5}NZp7~OE z)Q!CHW0nZ{Xq$=M)SYQX?KO#&k^vNK}z4W0IVO>z(Pxtnm-C zQ2nk{7W`M?ssr5P)L|T=kPD?kyK6R7urZSxn%m{zCXHc1tGI^TYY<*vM4pB^j}Zf9 zZ~+QR*mo1+I13%fj(}$3ry7|XA7O+X`Zt(ih=JXcZ?RD%5xUn)yv@_LwBsz$>M6)U z;J@c1_cv=HTS;F9^nu!Q3*U?ne0nf#_4f!Aq>N^8KW(;Sr%SI+99u-IWup$sf4WzCEP7U@7VdEh`T@utWj{e@6dziEfEsz;z*z06i=jo2 zdWZJ3rrbky<`a>>v0+V1elgT1ku&su^qolSj7aKt{VwkymmMpoj*O9QayF*SZVdZ( zvZlhM*%{>RfbtR(;Sk0L+>*9B$G0&qQuB3peI|_ce&1K1N=KCBfH}t53eM9OTuc36 zxmWnUeT+Ib6K3$$lcW@lzg!f7C#`5az(xl@z)*=6pEy=#C?b+hIs&+m&r@u&>QtGC z;Lo-C&^3{k2kX0fGyqOz>uYbMYWdnRXEB>)t=S6aA0)@?0|X zXueCH=BB{fM9yUcP&qaAn?3;QzCXOVg+(~p*hM(F1W4l}0w}{hdMA`5*$0bUKP3Nl zIHss{I0o1iV9X?@+2^S*cp#a%Zr{$dC#W>0QVkgZ@q9!G7Sg{0Q~V<^D>-;on(%45 z;B#ON{obvQNZmU8+6R9Y-?2^eT?{r-njhA$%ClOBEVw}S7JuB1K%52fa8DBJ<1hRt zR4^*IEzVt1Y^|C))wq5(Rhr3F-$x#u^Lx47PLw7?GUkA5o-!k3bSJ9ZwypRuz1l9l zI&G1YEu4cr{N^ovzF`!pY6Y$~Q0Lu?T=*EVI77vQ5koQ&5oWKuCz<5(g_?h%)07v( zql-fO=K$au*}b`6I60BvOkuNaJMoSV5^+=qX^gp;vc3W%tS_BS0UjBk0k*A$-(a?E z6sCqKx9ZClaxVLQOO8DTi1G2$C&3IhE%mVPWgpw^!|3bPo#!^O!hp$$044*^Tjvdd z&ILo=;`;~u0k}p7CGZ1)stSfW+9*V}sIS)9U7N&lJKAxLHEKdKLCfWjvK4XxA-11f zQms5j5CwpI3fu0h0dScs$K(zG!9@Waj7P z1~)!sPa7v-Bp{$56A>cAE!3Q%BD3X>dy8U7C{-ha@wY<)U}l#Qh+Vffcztfj88 zILS&T z-H{5d3Z6E^0vf#-5fF}NT21+{NYxo3jAin?$^T%xU!5*%Ji}@S;0LoFNE=%|OdyAD ze!1F%2$X*Ie1@>cF2HI53Jtifxg}>>{2!mrzhZvYm_1>u4m^WCp~>0#eKW7s=B1c{ zIaNR$_EP_wQb$;FA!keHN$LmuPNL#Lc=6CsWF+gaI&r=jdDiY$w=-{xr}KlsK~J7D z6MCD(@?AW1&?x)Y9zT46y<2I?UH~AXkt59#a@UD4{{~Q|!F@tX=u_KJOezl}s>y(c zlQddV-`W|vZ6cWQxhSd3p4W)s#}@((nTU|X_=$Pt7n0hOS}G|yb#ohrH|c4_ox*&L z0Dh3h`-ohiq%+yIJ@*@R-EA4Yh-Qsu$Qt{GUjjrNGk5$7XP$aScrtCD#)?knpN9xs ze}PH-yPR9MOdw!Mo2agIS~t=)0GQ~S-1ddkD*NZy4|xjZvHk2Z3}>S8Z!l1#Bl6Aq zYil?1@~rtuF}?A1@R0?}#JKSK(yLRLY*~%?<*jaU)qpZ+|JEPc?=F}DP@{B5@^<#V zhzjHkQKRwy=rO?nBd4TlZDM!xF!42YUlKBY>Yx$w`}P9wzQY~O5RzYxUqOhIeYFK- z;t2B}e=<|;#B^}-dDvLmy7M1g`c5o{mD}FKb|+-KC*jP6DQO4#ny*v!w#@AMCK+QcsA zPL%F4n&OAnVVB()YX)Z@&x1R?MMwR#Xk;Y#-@@|Zor?kgT61D_Fh4#9%HtXg5pbYh z;$P0i07Gv4zJv?es|J{0xAI-55O^rU>95a^Ge>jDt!02yVBEuYW_94HfDT=PK8)*8 zb=>ZEK7CT1ja9pA&I6*87|phaqyAY}--WkO$*I~m*J$?o z>65Kk!t>E{{$fE4meg+XkOI=QGPghK!(iBVzj)2(QQV-w)ARD;Lwd6!oeab|a!by5 znQw@v*9^7Jz=T`d`g_~joAVQ5p!nC(s=j}g3WK*Q_W*^yTQX$S+oG>Bo3?UeXzJnS z=i%n%cS@c7n_!fnzX7u;4~sesIaojM?78F?!q?PwH<0qOeQ5OkLs^ZIO6*uiOWU2d zM*xtAWsRrBWmtymM95myLB7T9t1e$`j+=vzLAAzf!zO|~1ekxY*3zI(SYb0zeZdUB zmX2q#^HAq0F;QV|J`qtq9#M`r@nMLG3|IZGb$-r&jROBhPg1d(#gIhU*lh4{t< ziI)O;R|&oJzxGvN(mlCcLaI9P3S!a`b)>QVZTjzJb4~JSvN~^fr`OM}M!*qluh02h zTtA=+b!S0rs@0$gL;n7W^KxtbG0>Iy`GSkj&rnYucsQc`@<4Pbth=UdxDJyePe-K! zWoPd3zIqGhRv1eC$uk6I7Z6mLhG+^=;wVkf!bUJ7WrO~_L2+hmQ@@xe2_qQ}w7dP& zKS{+9Mg9t{WKf2%m>!r|nJtx^<`h}@sjz-mcS96M)9DrYC}SOdEOLo-B)oWB4W~AD zT_$O}1sP&8M$VH|a8Bg3&Y%ULq@&e~Gl07uW>#)qVrdf)#$;W1+CAlfc06UPk=?35;QuE~oZ5v;HfQ+;6t9cYPha5BLrg%ryYj)Ww z{_Vz8u5DJTF?4P;qlt051G5@w1HL?MN9o>#AzFvL*E{~$8)GDeIJM;+u)y*rU;%QojOk|0l?xZfTR zv5<`lLj;1_6d*V%861i*L+EY}wU7YrZFR+F@8CW9qqeZ;)6CH^VDQin_4W3UI4P*H zB9EPq7<;-N&BL?56xRb>){Cl2SX*~_O^6|xDHj%rLH!*B;)MDOro?9(B2b6b-EH2i z^==;Q_U|-aS9SKg4`v=^c-g#YzY~~|Zn7YCaOIYN?R#PTX|{$u;&MPjiO^AG?!_{I zoi5z8Rhikr{`6@hsU4xliP5kqgK|R1f$IGP)xWf$zq4H{SIKyCI@Q;JI$hxN6(w(#Uyx%wilyO<+~T*H*6LA zzH}KztFVBUh8u4WGrL>r45rlc#U7Vm3$TkD)J(#5{lcmXjsCdM9X~np3J`34Y5(~? z%Mc;8Uv@F}6JyrrqEivWgby5~nDVi)`srEvscFPyl$d{oc@e%~&;XD1&h$6uiUN$o8`w5*E}wi`Tr5eQ?;+Mwu$UYEIa?VJw8By+QyH zZvm8!0kSY|5i-feE8M`&<>KRMYwQ2Z{axMTPxb1RfQCw2-Q&;HVW0XpYC0~+lb?UE zn&?>ibBhY_J{sF#r>;^Wii5xm8BDy7vQqPj`(uEJ10EspeX}M(r=`qOWgA;I9=h;> zepb9e>KOKw6_LCYWH0Q0ywg%7E)<2p4P`^*85~kO{>+7!9?oqb_PO|N*;tm09MB(R z`hl<=$ZvM(hPG;dYN=3%h5Z4Xm!ih1`|FFjB_O~a_UXoqdi(uT@98Bw)~~+zK3K9naeaO3`juHnoasTLPAQ>Q28A1>H`i8St^g?Yz zo)b9MP1sXKh1J7@{d9e|uxq1v+`o_XdA07gK{(T*KdBytps}I7yV2|H znf_KA(i@h$HQWQa5Uq){$jsacb+u&2`;y-y9syEk;sN~Lm&opyT59KiH%ezkj0(za zPI~gCP;Q1*zx>{>UO%fl0d|}B6y|9+@U1okl|d}-3H%YsJ$%AAY_n_ALzE$VA%Zs`;jPuZ%zZed)LTs5Q8t>91=Paq?&2tJ|VXo9Yn{@yhCKV1hRULO?R)WQ86 z@xa4w!pSc`C?%-PL1Dq#;{sS=ov?|<_JB(XWF;q~a*B+3l_SM{Tu^Ra7sz_+nhI{$ z2V3S>7*kk3*p|l!bh7x&<-@qrUL(PnfPE28N9u~RP zn$vQ&FpuEP{7qeMAY`}c^})Nh-@_pp$h_UQd!j47ni4tu(G~&&3gX6m6^>%7+E2Lc zq_6RKT{vtH({D8OQ~sINCBh5jCtFB6vSvl$H7Jj12waymP9Q=ut*&*{oc}1CcbK7gd83^y}o7DL2dyfXe;_RnAO^d;UHU^Q`iD)d2da`>Q40-EG7xM7lO+t^J}GUAHguVYyd8~%iz%>u3)||7Lh173k5^~{f)4XTfotcF>6!P@)dvgRg#2v4 z;`TlKVUvc>9YZUX$Dj7REG*cCK-2Ot0zK6gQa#c7xBn_gG3_`t02QS?hk+=K_m+Uv ze?2lzEZ3}q=-&o04r~_T+*UuOhY2aGCu(=xRM%dcA;P)B80g)@hdMN?)LZ0|kHNFL za0kVZ=Aa!*9h%8Tiiz82e5;L?les{=SECz2k!e9$#h|wMiG^K4aMkIHcm%4eB2|%c zI;fq>Qsd4rqigw@9*MopFJcy7d}!{0rorqE;rN5Q`^=A!O=(p1%>?NT&~foy#l*5= z^4PqI8T2=1ZoQlKwUv1K2SmGBi*XZr8HSTBQ;bf<16z{C8(ja|^nOZurr@|qe~p)) zErIDdLKnj5;WJpX;$T$nE_8E6OQuikuEaNKcWXyv_6Qq@i2nH1e3^Upww9wEWU#Sl67i-wailWYGu?rTh3J#8_NNU6MYI zkoO5EpHDN$Z*6kfY{dhway(~I8nZGUYfTx=-8trGy>F9%n~Sbkkus?&{Rv5gz`Ou) zik>?~3*uh(JrY9W$e$P_1P_Soqz#kChyYjayF?4(QDTDZGW_oLxc~P}{7vx-Yxa!teP1x9Og5a@+msNn7DpR+Y8si(%MhI@Zc2I%77^fy z@2b@(BsD-qR#?T>VE|GYpCi8U&NhR31}cU&BYC9<+taqheth{l{(5C8?PEYb8k*w+ z!YC)DPE*X6I5Qo{u>-CEX)_5L>7{A1qm{W9=JQ|`3*$GAw7KYd5rePe|K$oN-& zFHkKb5~^>v&JBV~+S}wP#xGrDs8`m+ZXG75sS#c_d$=(A4=!z^s&`8JACVX?=%hDa zF7%8@q9P8u+kAYUg^U7th|fHnq0yYb4+(HoKn{Z^KLx5S@|qsYzP?=9lxE)4A@Lc0 zB!X;la4`f#;Ze(NE_VN&sj^EUCH&)aGRh|Q&{t&_Gg|0_#{UVS0e`%)jC^?v0sFvS zf(YgFEP}G{Z{zT8z^s3O6|o6>MQ^41KKvCPAu{q8e1ntiyUQ((>q`iXoKqEBuUT!X zO4)CO`>o#GfBfM|9UR>Illcu;xb6z}syQ;>D%0+ap63-oUSn0Dwn?QCdR~ zru~$4)=yJo%|m>8W1OOpeKqEx`+zmbSA&G2{qxtwosuTi1GQDNakX7}v=q+!>zdeT z)(xtFXpY6`7cX)x%1UJJpSkN6iPHelT?@6*VVecvz-()`?v?M9T-fn~J$J7oLOAab zDH&FIy!rJJg45FiCG(7^-X3%u`sMG!4Nx#qKOq*a<~lB>#g&9OY2|tcaP>Avaddde z(AG5iptpw|eb(48e_&clXOy{bg-PVlFih_63$3PfSZ@1>--luF-^ce{T9PQt za?pUl2+qaYbMBq`ny&{@!Rf_Jb;MT>AP_(^m;KPv9RXuSD#6hAK7#h!ayy#X^SEBP zs?O{+k)y%O#pom=IPl>nGR^;avip)J1pa*XyR-YL#ONTRxAv&}+396FTyJqSdE_?e zIU;20<@n_<5pzGV1?z$DWm)WpMsmm*+slsF!;O~VwELD-P50%!5LGQR5=fjl=&s1> z`P%658}->~kmsq`(_)UPyt&RJ)$_$(9ptEu*_xrvMc2 z-%Gx=fXhxL?`*CQfO&v8c}ebfc+j_K=$Ejy1V|LIpr5qgzS3d>gi#9)a38u^=qE-N zjC=;nja;QSQeYr)LIpQa3C!bo7AJH0`Z z4aXd~c*t2B-F;OQCCDTCcrnXI5^;C+yr~p;sVOzDuCT~`%v2+MyIK=~0Q!5_Ec*A3 z>@;=e?BZxnO^XOQTTI@{DCoApv`ilTn@m5k8M?S&hMd(U2=rB!)o4kC|5xgNh(^_= z$atunZ6?%V#Heq?)fE7rzr0uiovBuEzu>gkOUwfkoO|;_65P(e0Vg$7P)EfqD-hKYlA^ZG#c}^W{+j% zmejx4W715b@cP)ln~{LVDoB(t#W*7ImP(V$n~)GeG{`{G(uYnIWwFYx4VmT^Nf-g- zk^}=}ggD7nD&RE94;5S^{Gi?gH!xg(ZVq}f!T?3lKJl$T!HA4ns>t?^amAK}KBGPv z<}AKF^8s`oQXm`Mr&(@vAgwuYGrPv&*AC2;QE<@u09d*Gdw<@+QxEs`oud(da*PN$ zxyS_D@SXC8NK|38D>JiJJA6poHy2DA+2(5X2h97O=*jiAo0{i4KJRQlK)8hqa;<4@6+Gc6FbJ5YR{xE#*DLPzAA*;bq;0uF z%D6aNGs|rg8Ib)AL*QS~ziq07%l$jIxMg?%r7XU>f|7+>A~ldk(iFMwTziQ=Zm*gB zi*>FUF1nwi#e8;JOBl;F`!I z;h@oqVZaW09uInqw6C@%M(ta=s_rLd8T+zD4%i{w>&rcd}3sxEiwF@bVs8{Jx9Y$ z>z>6ryRgi?_%dIid8ucl=8Nlp4>6%ZDnZK*X;%hx&D)wDr1Ntl9GyeSxqP}~GW2MK z{nx*V?(atxPb_VsVEEl!&g&ldY4h#rrDESSG;S><4$pn%{^GRB-6b5dqQ|6>rCnd6EVY9%8us56AjvoG5%pocwmI z2|DtuRdPyX$D4JKu}TfK(81SL55~&(;6f*K|1Lv{fV8JaJElh~@}3r$_=5^EKinqc zKgEDT8fH1JwD;3car@t4QO_tf#eQmWfZGhd&WIH8UD-J(qUpO*zh&+_sR_Sq_G_NK zdXn(Xc8g2kR>~a;=j1Q`F3YQ*)qSl5cWp;^*Fes%+iY6Q9L;~Q3;z!j2vZX&@Mhl6 zS+*U$^?O_3wmEtHUDZM@V~?MyBS-&6*j{~tNRQ;*qZQ(bkbAM{l}6jGlDnyNyM6(? zZ$&A{Mj+i2)oO{F)LvFl)Uu71LbObn$!$C-iI*b9aQ8iwRzYpHX;rh*#YBXT?)_cL z9i&ozS~yp~J^Trz@@QJsYm<=n<2yy)%G+o|xxTxkuiO9XG|QEE-JkTrovpk)t_<}& zoT{5%%+4;cHcphtjSm4PKlgEypn%4BxY|5SudVhJj~7W43br^ij>^)jiu*F{ewT|Y z{26IXjKT;c?~iu(l=k);?>SmWYbk5pGQ37~T`;9>+IuEc@3n9cu-R`olizw&a`Y*n z0}F8xh}_kCLST~D#DC5N%3 zBJI0($7EUId-krC_#eHRYrW(1ET@w6)f+Lw>gi>FH?PNQn;V@awa$CF`A#VLFtME# z;@;@q;KGRxiOw?89-%S`aps+YfK>s*>3qq%=D&vw_{7Gx&d2?7_6rod_~MHV-KYGN z%h&c4GSF~Kr-bXK5gQ_eqd(_Bl=_OR5?AMFzwC|9#--#rB_#0f$Bpk^1LLIFcjLUN zSFr3+YRy>)Sym9}j3XnLKN9cnbZ;r{v@9R4z$|t!?8CLpmB&pc))a0v{iKI$e>lc} z!(#P*1Bcft8G6O5L|E%A*;~g&c)?jgj~0ncq}L{TQ^HI2s^I4Tl9RA8f3pCeITf|2 zCFN9olrLTtvn0PCma#v5IBB41AaE@VvJo&-!KbFgaY?@|D{oG7!6f9~;LF)_aVcAh zwRg>qcttU3G|rnEnm25Y9C=RILXDS3V2L?xehMsJ;O zq&*)uva)e`Z>qa2TC8}-tYn1OT=3#sr_(SCN8&RVYo82^9?YIl3Yb(08TfD^fx4}X ze6m(${w;&z9=x8%u3KXo4CI0MWM~&$sTYm!tUsEoRT01C_};hl{l^cv3vz8`sByUP zg=PPB^MKvo2hA_ee{ds9xRBtR1;$OD`ggO5m4Jg!TnC%!49kjUX1|9Z5nlZeD{NUO z8CjZldN2G3d=9K!ZR=;!>nHJ&avXawH%r!roo*`q*NC4dhH2(K8z{IidEx&%S!{tc z`9$MTke}E6TMhm zxjyO$n-tq*x5o{fB}0fI*;TBi+~L8a3qfes&M92)5WgA=tBgU#+js2?)v=-yZ?J@K zGaK#M%BYUN)TzY-qfN`YoIO-L*-0Xm@Laxh)8NgD)Rq5anoV6fXBh8i&4<#)=-EF1)9j<^a`;x)>ZY#OHqeG9Q zx$?bdw9PU-y*Y+5&J3C7M3{mqI*cD@ehrO!^pASj;f)*DK)Zy3+dgs9+`}v6A2rcKn*F1w&hOZU!%=0f8MLb-ju|qsS?~? zub=pO8#&W?eJ}cVcf#d7!y;DdsjmC1+TDzfi~4s{Cx7kCQPubK-5js#fJe#uLtYosG`ni4{ZT@3>qCb1G5bj$ou{&REe5^LK`#dN05?v<;P+Mk9`H+c%nD=2=St|bqu zm%n^2{9fltlgq{0Q|Fy~AJviG&oEPsT2gyUnZ?h1-PY*Z?E~_f?xk+=u77`x1jn3? ze!AY8guY3-#p6Y;ZIAC3ZXu+z{=5HP8{W)%sW6;&1BH|cnx&-0sbn3P)3r7AO`a9; z*a$Rf`zXX5cu=&MXK!}DvupJc7M1o|xiB>NKCAwB;RftuiaR4s!rvxX-$j{7c=i62 zdv6zS_w~_Uk^5bIo^^MI<^X6}NtAEq-aB#7E&wtr(*U|5bf&uO_Lt@zu zZjsPl)NaUTQ!LV@p8c_|VlT}@@jp&s>^XHAjOYCP{=d(Hon>63KI^^dVTrL~Qu1z- zYk%5xeqbXj-VR@8$oSJFKXsb!gwtJrp z3w4s*-`{&;?H}xlI;Lr$yTLXf<$fl``i21;UDzwApNKJrq3gCfhQWH3WT8X{>oj=4 z75@8+X)DW6GTnu-Cy^S4GxL-T`t6!3+-mRI?qougT}ZcDbm)^x7=<`a+i?3X_xX5m zUhMxs)mE0%T$*2E&KW_FxO>$7o6Dh}Jc65iMgB6>*8K3$LY$&*QPZ@Cs+#^{hlqTH zFW8y6DH_I%$7U34kJB!H%vX4;xOsDn$uSh4*BUliy7FMJie0LiHni7&Doyp-g5sB5 zTP~OsyyS70i!zELC}4MYO0;f!y?^IHiQ2dk zJU%)1muh3&hiNA%_7#^qd;WM%4d&bMxA9%Ez$6PXEA8%ISDQ3a(Zb;n2uKH)!R7+8x8 z&TxeXthVH5_{`6%3pcHrR_X1mk4z2;Dr7eVOv2^O6qSTN@sGAX**a)Co?q{`mtRtU zx7STs9Xqz?R7b23t#ST66U!dj>;B&L`Sz!P-F}D`v#$t-$@koSl?H*W2LUpQ;u4Cd zMwPVOjgN8=W7{Rl?}ny$((26hf{c^6*08X{-%2~Xe|{L1n(n})H)3u&<+TKR4Zl61 zlGG--S>oYfW!?V7;_2Xq(~#lL$(ekiM$GK!-OdP@i7CF=rHr6)HY0IECJ;S2+P@qC zLHpYmikt$rcfjtAwl?>jZmq1}kKkS*pA1O}hph*gqOB+%6b}K-qs`k5moQO!ydi;) zyE3ZV0lbM3(%8 z-#!t%N4;#X0(Mf;SO=6*jpf>RcYY?B!>7x{=LGnl9gX-4%qvH$Tz=!N39>{x*lHb3 zq?x};=O6<$N*rA#zc%aqPg`3blf}<6GNQS^#K5(DE)ITbG4-26AbWS2blr$L7_2<* zFJ1v9FwIf#md19|qJ6;H#sK82P?m%)zOD)um5fkhSCPl*Fljo4K3(YSvXF%R|Hv9;`Jh>l>hR-&L#kCDsNIxsJw1kCM0?mi|5B z*xP?=pRNtsI22@aP)9v>o5rv(~U)iMEt8NFE0nrr|9Qjtf06Ru)k0V>#4#mpC8eps4M(Q zqkjgE`j{B9WqW*4YriXxRyu~y_By?F#=CkPCDjKkZ+Iwur)%zF zFg6(n2cmO9x2&pZ^srC#=pbO{aLa$wyHtPECm|`L>bxc|!k5tT)@k|hZ>3W`aoiSV zsi{XoSoP=KfYK0Gz-}6N;EBU(U`LuU;s^K!$&R;F1Z?c}qrId3gQNA@45;06bu692 z3usQjfRbqG8uC4Vi!5PDB+9{|VSy;E;sUKD zrTylZL*iCI-TS(b7}k-tO2k5t@e~(3Z=l2Cl9Ji*H@Zn^;f!2iM6imC3={`f>YcoO znMs|WYmJ(l_PZZfYW#d+(Bd34Z8@>?2PkCq{(T3TYpB6L!{&E}zeB@zV*U_Mjm93A z826DT*i8KIj%dp`w6R|v`$g}}o1q~Wzc+d~u6DGur(|ySo0l+_T9Tok{JeYk0J>Dw z4u^xolnX=D`z=h)|Oax(`tJqjDDfDCzht0lN7IlNWC$lUgEm|4ktrz00 zxg}`boRga{t{iO72wlo6kh;~|`K4nvKy)AerOD@hHw>A$1MwzAIq|i+Ac8%g82vB^ zEhnROmSCI+Q6Spp7ebKm`GU1P!=$HF?biHD`*pOGI@P<#7H0_H^YV(nJz)VtV7^10O?HPv!@Bo9uGmL?jSIMM=f)hqNOu7rSCz1DL=w=NF^* zAs$wnGScIud4XqKBFM<2Jg^awM}}Ylh|o+>ru1g1YTcs?&I#;6ew?FFy7;ZZ8aF1Z z8i(bs#_LPG^`^Pya=y$ex?uiW(~Z2_ZSymp%0HRc1jReW$tTMSQ}&op_!|RMJo0TF z*@EHORrPP}dk*>WK5SYdJ4rO@GxtRfJDy})-9^B$@~;^{~+0*jLIx9z4;zR(r~s@zKsO;V_pP% zyCO$Q*Q#T|02A6s4t`9zH-t1Pc?s%q?ZiKsU!6L1WF#biE|H9R^XVpMaI$%hjD+Rm z*V%0CN#QLyR9$;=m#=gIFInwJji5*Xz(Mc1&m$jq9!ZZ(_i@;NaL3(+k4`&zg^CAd zdCB-)O8LqxYoG;AxOK76*=a5+HpQtEX+pdL%jYY{`>v8Oi`vzZ!yg; zI}Y2b{T(N-!RNKEjIqSsp{QDtbc{x!#<%awbI7-ljO_|_-HhI;SpT?(LyCC5ia&P}X~0JEcIGW#m$KE^>;JN*5glUCZCjYf7I6-T zw3U|e0nNHuund}6C~M-*omjhhdQ<7&70mB`@W9@m=x5B>{W6xLGa?wI@FKh8W>+VR z!MVVv!3~dukwEC*GE?!S7SCu;SAJoODdCUEd-AQf9|6_;UV-Vth_@-%cdf&CyOekq zDS^p2zT~_}yAUCk3fSAb(4tLPETx`Kwfv<%XV6cDtUY>co@jke7a{2Dix>*p5LKI% z5#<=(6qC9#!PIA=(A&~Az&^*)(o*=!4OE5=Nc#1X9Id4JmW=nKiJc z;yW$1aQ-Eu3ysO@$CL^bNsFFr%9kJ22j_0F6YWZ!)e}`B!$%jWc4{&4T2dJu)7qPS z!6t>Ow=!ZWS)xO(mI>dB^y56ov2KZjeVbf-(c;_JQP)a+?G;t)jQ9wjd3j=Z@sYKh zvZtsSN#QGa69zT;3~pFrWKPAToS%acte=l3ND7Qkz8 zw6D~dQ2QjHz!Q$>NFG26ve2$fxE99laii_yPJh*TqyZ`!em<2;L#(1Yo!zHrlT|L1 z*OZ1(v4^W=R6RP!yaJNybv|YljObfOYhsIO-L|JS8J?M*0uq*Xg0r98-63#!mb}Dq zkzX$*m0en1-3F42SpI3A-#QIjT~VD`76=b$V<{rcc9)R{?*()Rc=JT2RYIWQkK%#M zxT|zYXz-zJM)fJW6Jo8;?9)&K~(GU8@_Hp!#rIe}lvNWw&Qza&pywpQ`GL zPd7Z~Hp~rh7ie%WvpqrMvZgV{S%(#<^})5bfo$)*up8CsS|ab`>V5CC+sX4UzSR3H zPd29(h#_rpD2eoCDG3iWPBGx!_Mh&e0nr{sAL9J4mFL3Ypf#8l*Bms?gmq{QPYl(y z-hLuOo|#<;qA%D*@*FT|57e!!xk_7RWi}j}r^|jW1GL7+s(mrQZ0OtScISy8Gl!8X zswT#z{Q1r0Z+5}Z?lzKXT2QmMnhpdfcqawS17L498WH+Lz6V0|bw_gw%`z#KwIN>9 zo=2<$?E%Pz!rjfVjGh}ez(9f>44b*%;ce)8dirmTtqjL38Hv|dzBp1#QmQ(USvX|E zD_+b%d1}-@h2)DfB#j| zCQIGmmH&Q^oUnpTKS&GMZOQOo5K6KYfXWFP2Sp2~{qda9`lIu$+pqaZF_wf<_p|HU z_r^RuV#k=KpX&1;#V8%TEqp*z4xw)h?!Hp2_g-@k%<;FZs#;bPg&S|UclCUmEAj2M#v)GsJ#5!gOcfJ4a>ap2{%=`Nf%IB5nG%vO zglsR??-vn^o4M~XJ?&oW9~3|>P?00`hAEs};e_DALTGROC^wCDOeeQnR*7L#}w1Lx?jMT`tb`J=xpuhV_UBqk~1EXq7jOh^4J=X z@|YKr7ds^_R8s{&o9R;{_a3{@lD?~{tF8LvR0Vw~NUeuio@`cz7IeP2?6U&7Q}mF~ z_GCF{C{G=KjM%+6>~GPl_DCF8em?*1^}Ts5|C8dNgn%rDiA?57QJHUua9Yb zlBoAQ(EKmykj?G&?@3;YZ>xK*f1jO@Do@vEVHxgQ{w`Wt>sHyN8e^Yr1+=fnO_befo<`OylBmjWkVT9Vp@6V1$$-d|ed zAxDd!G5@=*`D>MB=##49fRbO@dS%n%3p_WuZ9hL-^jE%$^IIaWM?;#jdVwlYWpm2GM zMe}HNp{A>Xqka6>PjZk|Y?EN#k_rn-KVg6%ZnXlp>mA`R2FjPA zX}%9HYpSp`oTMA|zRW~EmM3zSx`o}SOHt}p!xcsDVFi5>`N4ydWpX-->gLzJo#z=I z7-ZK9t!L8viCCQfQ{8)q^}ZS5y$?m2lZk22`?_GJyy|oc`=Y2*&K&L~sxz|7Cl=W* z%AK%yaxPNEPut~hmNO33<7$&gYtVNRx3L?8#~3rRqv#?H42tTZ`j^cO*Vl}lbToEq zd?ep zIQPCYauObuy4<}G8h$tJqalgDz~P2w&2I77yRTOew9L{_?jWd`ry@e#hmA%@AYAgv z=Fo^K5f2q&lL1!X9EJ134s0W5bieNQODcJ3Df#vFs7|J{j}om%4z|BuhR4M3IF3%U z)btuJYeiP7QZ$c$%(DD`vw(*^mr7zwLGO5);dD+G(zFdyfo~0Hz7#<-P!E9z6GIy>I@m+0nmG8R z#U{+j&#VU?l&mtkbsX`82tGS`3uBI-lhMDt4Lr`wB-D0yR^IGGO zmS^??S$kXkrFDw-lA`z4{hK{q0I4M;KI4$jcz&Wl)SR6p`sns0Jz zUj~7>zk3nP#Ou~&{iGm2rGQ+WFCZTQTsAQ9{JoDJNq$J*VFr!X&E0=!naHX+1+hK| z2XLqPFn*KZjFJ!78`@w0$VWY=wFLf4#z+8Q3~VuScQ}U&MaJ5jYxA3GsWZp_G@d&X zY?*wl<9gKX7LLw{G2!o~q@`x09xE{}j`jmun6+HC46nnV{YHGB%Y@1BbvpOB`P5SK zp$EDP&l_{|WUB32o1p^Gmn$kQFw-Hw2aY362WBxT78Z~iv2j@stM>&+4;z9#72`LT zYYVj}DD=|)H%$~A^OVv=QJo+=wuk5U-5O>jiE2<}92fzT)Zsj_(w6@kvvDXIL#fAC zut~joe;f2jQLPxC+6dXU4euloqBYa4hyKK8qQf7>+e(EI1c!x|7&Fzu^ddtt>3v+n7X+zt=@Z`eV6heTj4_L)0L!c)Um)bn^Z-C5w2&SP>nF=bLKq`H)drdw` zl$nlMg0yGaq?gB-1IR2oV>HVUnBcN`yT0&kDh+1h^>vI{bacZb1k za~iFx|9BnsQC#VuD@sVX6OH(CPVMH}h0u}ln8tg?;gf$4h>ljvIko=V?=2aDs-y+P zi+6KP4B5B|fzRz}`i|dW=Y0TtF$C;Pr=NI6cwR&-7gh9oVr>j6wEy(Q5r|Wd?M1WHzZ;r+Ey2bjBr%lra*vncOw(8~i*hHOpUp6p$GT)9bsbyX9 zv0uzX2U^K(b=0bXyzO5y13zM}Z=m@xFrB>Ys_U=D$ zboKY%pIby?(Tt%N}hVpYoF3+IyLsoi1$OtfzlxPJ2lKhZ+4SFB*=k)87Tl z>yOX-viEiD&qBj^vw+zHQIs&tLHi^dc2p$;eZX2;r3X`o*2N>XfPHskRwuJppNP&H z%-e8tct!&x=2-PRCN5pxTQveIoRYiscx}w`wdO`(k|9k%)8pEo@Bl$7+b-ZRU1`lH zA;0*mlBwCU_}mvSyx884%WDH_+ayt&lwbE`zmm+9Aj0K`56#NSs!=>DiMipuG6Y~C z6+8SxyH7;N89;%u7tx$67*6hs#`t>y=OjHx)6^$Eo`{@9Xn#9j9IX9Zvd|s-Sp2 zKAHVkgKgXkx4MvS&HYD{FMdwAm5*a(ua*l{YJJ;0bnQXi)m9veVMPd~svt1|5liM8Q}K?o;&l zf}sJp#i-GagxX38J$4yVFhU5R~!Yudmb3kcQH6o*3b~-#8yW z8oe(Sb+$Wa-~8K&Wls3%nC{u7@n~j`F7OauTu3m@Lh*dY*p|r>#K`d9mL+Yk78c^Q zxC6=KbM`|eEh2?dZMMa{0U3Ff;T%jDqNu0*D09kLxV^%lJMM3zhu^& zc+}!OIz>>u62tpxKZ;P`d{OcUHzTCN-#^^Z*!YEdg0ey2G^o8#uzZ;@bc$mp|L6|6 zWdiHQsEM40w4|MpEK6`L`MGcD>v=g4w{+@s#QpqhB52zmg>*BY`=zhmR>|EkW8fKm zmS9Cx9fod!!2nGQFTc@WuNdCbD9t!!YzJFJUULFI)3olX*tF5lWdrgn7P73CFTb+T z61Wj)#S0r^!8L%~>y{TqkO2HU6gQ-~k<`4IHC5bG?iK$GY37wu+9zT+tzB*IyzcPMYjB(ivNGC5#S>?n}i zdg)ZKlt@I)%Ihp{Eb)<~+sjKdCy{sVWi&>BHCdWvedA5j{PH!C*iWF1r zSygxaTk6;#v@#;rSRI7M<>^n!EZxrtPEKLu=J}=Ej(vV^hNKmf{w?AgmX?4fWZi9qXWsw_>Lh(}+e^^Hqvt09##IN3BUOO=Q(tY*Qj{H3nW znuGTSt=GNk{K6y_3v+!+|rfod)!ET$*X}N zp+TIgp$G<*vT0w=C&BO{wXC22y!;1xp&Na~7DoPLOJie35`>lzAxwfi4{`}<3jYqZ z_UW@k^_E|LPIjnoHF4Y9{2QIhs4H1UdhSmwcr|%Du?WYRY2IYWCjXCI6;YVN$`d__ zaMO0GXFnAai&9O@F*11T>C2!am|$?@EO}>KdzmmY%`V4jXU@sYtdLf zj1LQNVjGj624m%zDbO#35^7b}2$*wi@p*aQ**JbXz4~P!RQT9A`C4*&oD4Hv3TtSV zIts0$+pi+-I)SD{lF@GOPkx(yns`7R$noHL&hlwhr$W1@HVm;HQ#YOk2#y(AQCY$> z){m3I@?{6hT8kcp-~STH;{$sZw!E?YoW}GNzR6?0rS~NI!O#;CLqyQW=G@u2>tnnG6 z7EF>c@PtYuPdr3VHobdljRFUL*Se1a*ny*`sVK3na}F01I)I-3g@r{KCU}kPK(Xky zCir%Qe`olM@;e0SN|R$=29e!RrhD|uet{B7w<5)VYUD(z_l0^C3<~uj-`({W!)@g{ zlezi&B7$%X&ZI@y-nwEWMdH>B$>opL*t0ZEVD&R=CIIO{R!R#ssGtsy5`kjnr*opC z-&)CI3{g^2oVbQk@B6m{Ews8OZRj47z?1}-3^2E{0+(TGN&Hz^Y@SLKjC+WIqzj7y z1ycO*A8i{irz(^_rY>_xD=BcU@1dI(Onj;)b86Zg*Q^?KYgLPNjDG)#3MCy6T;4G; zv<$1) z_~r(1jbJljXrisiv*%ML=*+AZ&f6@Z*qBEDYc9}or=5pd?|QET#w-f26$*&zTt zO2yi(*(ylVXlX~bSPHjM%X=h$soQAGhX&YCiwJ#k#ar^|+eqY;4+>`OC51b6?v8jD zx9y&~1C~Tpp zRR}h#-HSEigFVc{`t5Z(V@9!7c)?4GW?~*OuoFLqE7>`0!zRTQf- z!2goMkPH~qkzl5xJRD3i?~ToBj|=+Nw#BI+Orgl|JXzXJMG@JtX0Q?LdcH zDC}AQ;Q`<(2!(GcR@sN{%9h!m>qFySO&W!YBzzG{y9`sXtrEW0LS~lhG$#(@w8#lW z*B>Liv0d+kEnCw%St4SRq(L_Bv*I_YZ8770u)FUpY;qwX>8Dh{)=1lLAF6WYi{@Id z#cfbZlud3Ik9MB4jMi>F;y1z4Fkc78Q=BzuOU8-aED^^Dn%%ACG&&j*K&FHhL~^g# zpz2jF5J&R2j@x!SxL%F)n$xEJ^eevub_{3khF$WyDG*Bh2Z^D!Qdnwo+CziD0V*&p z5ambtN-$d)=b7rp&pG&!d0WY1R#MB(8w%ce*jV;oPmW+20sDpRI!rPFG$p)yHJk`s zrJz*d6X9?hnM_>4OVJneG}|>1YKc_bgQi*IH}ljIYs?tPA+jq~)1n(fg7*A6qw5)c zOR`o+c#cv|w)}*A&oNI31y&z~e242aLhxDoW%|izZt9mk3nx9z`XyY=2V8VwXJqRK z&W2~_EmZmH_Pae(VN~TgZv2=OzZvxHEd$Q)(_r+9kLnd21Ic*N`>FjOa@zI2En6FZ zFuGvdeT(8@ocEb;Sf5BTINcHZ;4w$dowD&qj6P?-Q{?{CE=?hS$5H;?pXL?Em>sKi z-ywaT&<97BJN=EDznKyou70hkyHiA}@W$E0am1fu$euBxkJ6uIV3EW~@xkryhfHnj zp^qu+{b2ffC;ClpmlJv0)Z2W~skbmL|3Bw}sGq|kLtZa03MrS4MXs8~nEX*rYe8iD zSsF@D2j21_gQF3yC8acuTGr5a#h)&=En*+78OHQ~sWG{j31sS+wuC>KjoRG*Q~u4Ple*!v!g2yGEAVDm zjSs&UoJ>{wg_7*eVW!SS{_g+e9GB*0J;yGOlK;4RAQbT>!J*E%49@#i&jwLMxbscnt8O;o)?277wOIKwa`-d} z0V;EeS~-rqgg5OGJ#|Zb+dYyo%(d8aSH|eb0RK6R6*de+t7m7bGX{Q(>~ip;4#fi` zi-H8v6E+auq3ybUAr4efiuNvl%6Hgy45AMiryd0#vG?Ps{z-`_{@dl(}yY|6gA&mr-o2hj#e}sz(2tI zMU6x9C%|`E_pR_PD_E)1(twCdD%^bWtz+HMpn@qApVHWdX-e8D&WP*0C3CI#3wzF9 zi`ayM5nOBwEUZ9APElP0{BUiXP=CfHw0G?>^?qC3G*&gTqs@csA*?^sfkeSIz#re^ zTiOsr0)aIRPt??IuO}p<`#(G-nE!g+?27{Q@FT~wNur8gR;rxgYEYT*<77uXXU6pw z`@5(?fCa+MmHH&>GUzI^^4_B}f`sSJ-;Tfpd&zN{ z)sxfj#Q-5rx^m%XVw7kX4w~q*j*K{9 z@zH3Tb3JR5i9U7eH7MwG5`VQ68JoG^w>@Ab$)Km zeu*Y0g(COMiq3lLlx)3W5IR+stcy>e-$do=A*k3-NHS6cAuA!S6)#0Xk}(6|{rfUc z#jc>dm^&8OW6$+!nJ{oa4Izcf4rCE&J5voWdtGK+@yV$3mpujb0U9Jo0BkX#Lrs!0vFRPw+vI&EC z=%}Q>(y(A4c`$~}O`~G4=M}qzhG_^oUniS7Pn*>5HMhaoAUTZM@dLr0qZvUkl->#v z?1Yxv-(Rs!cDL?R!s!D8-Lo>wxr9w{AkD<>FM4|`42kHH{W0AY=Sd(?#sM6 zJ=^{=_#hkx;CQ=xHxJD`4IwPvnOiiP6iQvLzLs<$;z(6nBDLH94NV1G4T4RAbJ`tT}Lb>ZDv z<}R?4<53xi0)8~?jRYA((VYB!fNkB7Me)Fq8IRoKCyg#icLE>OgX8D+o4C{u?>|^` zpJjye=;6#K{kt{yae->FaOey*X+|d-bRciqx>_5YrVjIrCPOa?=uzTq?zOb^(?vew zVQDN0mLj{4N_Zbqs1P zZLbci33t*ngoyFOSQW0H=S`v24zn%9B+f?QN<_wEmB$vqV#LAcI2$g-ZT z#><-xDxG8h2wwHNSfX5t%<*&k(8eL4=%Y{K>c>^WCtos+lcDvp0yZku#ib%0w zfwe|uluJks@sLbnIG7en!LGANtRa3&wn217+y`tL_vLFD#9-BL_13ar?I$n6f~7fO<`rd z2D?N8iXZdx{$tcQUM^l82oPdigI)T!Ot|Aa(=JoDN;LiI%683e__@^Bx>?8VZ1dRj z-@8{gWY1mHL%Q!*4KB2c;ZP9JwfoZ6I>SREfAiO~a7h+gF*XBoi%1fSd7^uYAvd{& zmKKZ7!F6WxL~AVc-LIljghC?60PAb-dBlQ~Dr;^aeF@>73Oqrsv!% z=w$bA=vFk2@Vsyi!xSo8dPD-N5=Vdjl_FA943W>$G>ut{(X(Bd@F=&9RV)WV&Uk33 zT+z=?QBuPcq=r%*3qRXKr~f@Fn2^OjZYC|~K8f}~j_#EO>W2AS9JK~=dauT$s>Zb_ z#y(X35>v~Lw>XWr`6B#BMUNxj&GuhNiRu#ww9`Hbl1H?0zptXujVPY0((}d2nK#Z* zT~k{d1r>)fGldF;f&9Hus8^gEGY%mEbS^ZtN_Tkze-$yg{8whTU^yF#=Z-FlT}P9m z#9FK29qSGv^L&j{&r!X$$TVo=+}?ZYb%Cd}dsn!7ifh)TCL5X0G*7>awe9>&>j<#8 z9&Qud^x|tWT3J1wVE*~#^?|QFLWMSRY|z;+jr97cG3FHv{2jn>#w4A zPE~%yQz1fF0|OGgexd#=&ox9@>yv4@fZT}Xy9)2v+oGFq$SaU4_ORqY;ThyjlM|C` zu+v$&##O66vk$2OgUs)=Y#<=4Aak4>Z0orZi*wC8PjXMEj5&BspyAA; zuOlzZ&8t{kCXVLo`94df(aT=6^a!I2hKIx4miM(u7!ugleMk`0BoL#4qvuB&`qLd- zGU_Ym5I})zU%H|fn+rH#`rWG}N`waNH#jgP_WHo+*Y~x*ZWI?8c(3vN8r0sd`J%(e zK}Ervg%EV4BhwF>0hrD&VzW2$^zN3F-@P=aVI{Bf28v%wc?X^zkC|k3NY?r0^EKT9 z3++yHFt9F8r_kxXB0@0QRp@ z~F4qlcaKa?5f$UjeD^8T|x}K}j={9|Nqfps;{1x+YHBeC&ne3FwEO6p5BR z2#QJ5`36&>H9Jb|!M+F*h|gpnr0iFHEmKX}tQ<-b$RV31Rmj(@>|7&iH_9&zYAqH2 z8x2Svb+yxd)bJ~0Q5;`4CTlALP`h(8>*a?Al!!%UYvq_xBA#6tPD`QVO0hJ)6#X2Z z8R|af_4UuQpKi_FjNhh?NoWceQw|}T-O6%<43YRP)JBj|Tr(l)Zu z{5|kyf=IL;Qrl8cQbXPIh@{>yR)z4W&gcU3bpgHSWDUx*u__q@&1KLt%PaSCDMLDy zRTEh>f`Ozrb+5poih`iSNTC|X4R`Au!Ni3HZVl2l7gSC}VSkvNh4D2;)o4p9{=&Qm z)epTyXq(lu8(cYfpntazBUCOdL=B$=jHSVV`|n#1INBnD8^mdwjQZd2%Alv2sN_!| z!bNs|Aut1TKjmd0(U*K{TH5L|CVMkcCEA)U68PLjMGVc#s&Ra}5ecZHmx4}F2O zhY113ir*QPD=S_sx?}Fc?QEKfLdvMx2=Jt2qu=WPaHz|)du)5a5$^S_$Xd>5%uN=~ zXR~~H@RI}qvwQ82KI{`^SdV3YP{rW5t-SUpFmB6o(zP`47$*Yas>2PKDzk5lfpg&X z;=T-;dfN*|NQXs^9~Tf*7H@b~pwieDwLAk4(5;{4UUzKvX7Lg{6$i>8M(bVSeAt}U zMf3j>V^zdX(F?|X#-f?B7q0Oq_57u_AvxatuX`~Y$6-SM2NRKiV$-)TaTx0DbGq2D zO0N)H!Jm}_xSH85DpDub+mZ_L-7GPd9AfC}akE6h2;oN@G>AIirHUptNKa*t7I`X& z_dGhzV%wjr1IGU;`s;BGI7+7MAN83Ivp$7+likCJH*Jc zq81gjp7O0gNc9QHk?tGoOS3bN;?blv5%Nk37sE*v>DM1nwVbDJ`upphu%g-Z(*C!WWi=Nh95{FkT+Q^D3P&*^8`HsZ>9O3Ll1wP@gzJ;cwl8H|rF=IBlP> zcU}W=+(Mbrix8eQC)~Cs&qlo~*4k42&Ap88xac^Eg^;IAbUZm2%-oUPo_2A3FoEaI zDlu~XRGXgch4F7pM!qojT7)p2>eXxj>F8f&^SsH}0w={Ox6|g%=>83lpvgh)F{C6$H=@aE^%XSocPhTI^&g zwo{yI)K6d7(DWW{A%nEfh@_k}M+KF`_xOQ1hh{kcGdz^^7n*HojLO@+N2eFq;bZu z+%!3_Cvy}P@O9j!{=Ab2-@1Iew(n0uYB^6Ya2SZlAebZ&t)s(M=If`W7Va|&zW2)U zh9|i3S+VvIClQR6>f>2 z0niB+Oyf*&iszDanS;DzU?4U0?Hs^Vd2Ik<@FTaHFcgyDXg=K++_HdmHSz7^1Q1w2 zg#GO~oPFW=w7#qOz|mR#jYNA>s2PEeW3i9Le=cDdc(E2KMdsxX9@S>`__xt&3#@A@ z)=)*uMQ#qSNZujS=S*uCd*Iu%EtQU#yZ0kv3A(i zl~NGor#}{D=sFTGpsmzKYtm6baBZ;52{7r1viI=I6Im9Ec!?~w^=}pCu4}*Cmw2?> zpt-4z{JvtWl3}Ql%rsnQpQE#V(kjQ-O3t=%LhS0J-U5jc60RnT45HFgac+}wL#)z; zBj6BX{$hb?C)jw%Q%+K7XHvFs$MDPr9h1HkU5T#93uJu4uQfTnO0cGwIdi@6;qUcdQp_6!ugca3ys+|qK~IAVgwOo*5=qz;c2&Agf_ zAb%YptA2_jLWvri;FV$1l(^Zg;%bUtC+z?z1UH28EJd2q7K~ zJetSjo<`Yh8Ke_rDM*PV5-bxO+9vZpI# zouu+~RC%e|9NGPSx5I3lDcTG`7Io(Y=PMS_zR6t6bkn`SyHSxr(bG+@3-)}5TZ~Fl zpez4EJba-q53hxlXrm}+df1Q1p-IDX_kEa~_0+qZN;RFefM#51oI~lv^P_H;)~I&c z<=7QZ7ivFMeLS#Ioa0}pvx)-a`u3Kg6LfHV=*kJ(;n4YbsfwFRN_TZ+9h4o^vR=tJ zh;G%xXzCz%mx%sm5^^O?-y=o9IAkp^)Mkbyzd42MPg_T4|Dz+RZMt@=r2FY`&&fRo z&ABmZ^ufj$Ep+av0diVp@WBlV3eDjMFGPo~G<}kmkkNCv{alUuE^=pAe)azA17BWg ztI2Tbng~uZM1$Rb=`Akw{3{$i;;3}gxJlREcEgaG=vB*h+m?18DhjbmpN<;{<(f*v z8rk6?_*Atv8TK|^*5DYMEItf7)%`h#ff?kuissyC2(?FgZ%BuQZP0hg=%z8l+nOk} zF9$oQ_n-^imhZ|roOLFbGTF{{ZQT9hJ2ij!A>ugFSeY{Ok2{;_zdnliJFREU{v6ZfH_xgV^jAtQJEPBRzN{DBFoQ+^GC$HOQj$UCnms+;oYZRAZ+Ow43xx@$xTw}-1?-eOJ#8(Jn*8-iucs$>wC|50S<0+NN(QDI=YPSf&0yhw)P7%=gbnv zl-;*&IWNH1oA1v(;p>8voCml>0IHdSr~ig2F4^KuDqx? zMT79L4GcPSxhmTyQKZ>1UHWNmiK4zA2|;pi1f0PiZEZXqn$fs==~1cJdrL3Ts%KV4 zGtsaO?}7(7ONFtXo-*yBx)R4*N-+U{;}u>Tt$w$UTAetr?Wejmce=_L=hx?WQ`cDw z)ND4$oXY}1zq*WpLpMVvG|l&XNaR_h-z)FS%0!Td6rAK{P{_ACV?M{SlR~)!o%GYBTyN$+^8JUvGm{P~k z?%O^Gtw5K0K7PvELj5hQ4V_EpZ9JIfy+IS&YWC5n@I8e5aD~FGA6>Ix+D3HMR=#Se zElWjclU6|MY{{=zlOd(C!4ZLi&o5q9?*(iYa)UR6e>k{UYPew=wGb#{;nkQ6OVDB&(<1n^*jwgD6v$ z$$oL*t(}AFW$w9ley(20vtu&sA@NbYw*vZ{mMQL|0a%78Pw%g;3kG&~b(I0BUG$FJ z)NbNJo}Bhx&n@gH?$N8^NiOH1iq6IZY~f&B*x?yvzMYANEQYWueJ&zw|w_~qV)?UQlErW!0bq}=_N57S-{xEqY~ z0#c)+|MJRz=RuF^1JvHlIF?_^^sO@1zsg>$>l0`e(>%wYl(@lOL2hDL zMMmgkU;BKIlXlQbRdY_~CcM76rAbEDu;8<`E~WE7k>ma2(p z`#ipuEDUG41}g7V=xfEHnrT&~!OJa4E*~D8tved^cEt})-bRK44SNk>wM@a4F+%Z+ zq7;h|gCBxJfZ;$i3yiTK7mUS97^i3+AZ3Af z0x?zh^KU0k9efpYGM>oqwQ})yBAQQraFPh;fI~pkN@<4-f*Q=o=y}!0iID3`t5^We z9qsV`4<%BP!lR8i62tt3$qwVJo|^hIP(j9Z0<>%K>50d%O_Gy@l9h==h1#kg3IJ+7 zL|}D3ZQ2q+*pM!={IEV*Q1eIb`kExG9DmJ|vZHckFI&id%Gs${??}Z7)Ft>8DLC$aH{QnR`K7ZGqE(#RCI7M;p)uc-9G=}q zcg$OMt*OFhzb9am{ub|t^(v$0cqcz^Oad>sh&l<&k*)kqs$_x_(fKz^X?MKFE$KlN zFFF^>;(D9b%CDCX^FqQN{;(G^$a0Z&vQ@5z(qA?1jg~RUMvWd5vQP{%;U+EJ^!Q#6J>+8dssRgC*z7oJ1O9^U z_ix2k&TSE5C(&U`SVF4WmCz6Q`X>kOdWgW#T`nT}R0(4op}HwZkJ~JQcPc7u2~SA% z6m+p{gDh8LN4`H=lx`stQk@UvNS7Avck5vO%HPDkS!-sSG-dJcht)gLU#y)#+WF?w zmIYq+Y$annd8Y4yz1^*!*RMsV-PL$)EuBP!JX9NOn7eFC;){iz1z*`Ahd>!Z+QAZSSm~f-f+0k8HMdY4d(gmfbZsB%8UE$;{KNEuWdot=IS>obYY>K zod0Rh%4{H7>#^iOgM1*OrY`(+G=X+7%S%;STrJrHF9A;E%h+sHU{Tq$98`1=k@*I! zkDlTm7rbi*^M!VHcm`eg5$n~jsZopEi95rJ0}R0_=mrjG>xb=?@*r;#0@88I+L#(d zm3B3SNp>rraBS~CS!BSMx^;U8gDjsce{c;bZvVb%AzwGGBIOcj@jD$_a(9R%b{X)~ z*~hL&d%GlF>tc|V@-6%mYIoj=_Mq?DxA$``wU<~E-^vT;(<6~jWl!0@<3Zr2eBV~> z4}p|QIb+k-PdUHDK1(XRDlxax{!fC{YGtGz85-~9F0@82UL(@(tG@BBee&bH(+O-x z^{r?hYgg3p^We~l5U?_j2@$9!kh5C4j{Dpl9Pm9kL};!)5N&+=lQMQj;&GJE(7N9{ ztm2Vlr8i8OickYW=J54XuZhh;LOo4v$3e^Z+ml-{lWu{=*p8NuhwQB52Sj)bM|(7W z!b%K>kivTI91@J7Kx)0@c4>VqHrTsqbz=Ho6%@pJp2P7ddI_b~H=Z0A2yuhVH+T%g zz2L_2t(X!9xxRymEwKm_0vE2erZ>sW%7S=+%3wP zMP-QLaQ(A!n`M(ZKVR3DA_@-x|~Uwrg#G4hr~$u}wlNjP*=O}!rRS$F|i(=EwWYIi}a zl18OIqB@^RkF+QsTE3mV-Nrh3Nu2moorOPJ^TDza$bC~DKYu~ENGC#>rLd_OTu1KieU1 rN-{v;J|gvhzBB&+U;j^c)5;G?S#5K9^tXme1pXZD-R$b=oP_@Y>CKx= diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png index a6167e93fa518ef2d17bf30e0fa494fad8204fcb..75849b8ac5e7b987fc6a8b3c1871da21e868d8f0 100644 GIT binary patch literal 10518 zcmZ`u(-SH;_mJi+!qbO-Q9JO;O_1Y!QI`1d+=a^>Nlwod06+o$H=qDn zIr#sI2yXJq(g+vOIG9*4SvU#h001nEkCv{RhM6b1lZ&I3t-U3=o41oCxz|@G06;Ff zR!4cUnh$mR65AElpvjUC9jU=r+mU}P-$5KP`1YfORx8S4%B7p8Rs7G#gU+7;x^~F$ zHBWmT&74EP!)6SvPW^7r^MuTbZQqVVGvk{t+g0U^9RW*PW2t$=&z^6Kt%YXhtvsof zxMOU@S!R%`86Mjv-%ER&T#KSa!|7()KhNE&Gkmm$P&P4h!)8OEhD-!VInm<|U$H$W z*GgR+cQn@acF~kiF7qX#5E7oDu0_7JYXBPG%&ξ|`qMyDuHq!TcVvNTE1GllVN! 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 literal 8914 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 diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png index 503d7305d863f1504ca2ecf57fb77ecbddfc1447..f4db572abbf55c4466f15212ef54e735de561d29 100644 GIT binary patch delta 2161 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 N@O1TaS?83{1OVg%pgaHo diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 783fa026032499189b063ed6629144ebb9c15192..06484f0d5e034d3db25fb0f5e1b3af06efbd9ca7 100644 GIT binary patch literal 15086 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

n@K)lm$&vR1)08h`x|;pzL=68nOQl;wo7WEq{*v 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≀}9PzFB*pb^3Nh-pO^&K5_B_$>KB_($O zq*%DR(!)NYK!ShIw+Ocy|6Rz>YTVJaKYs8-`odw5K!L#z8JdTPI|$|!03lp{-GhVF z;S)!lOXwbA7pC%D4g{$)Xkizw&NNqs2Wr8ElMlIJ+oIKm$H$cqe?e}l;^LEb4i4si zenyA8nkEVU?N|V3D-UEXs2a!_FzX@XE6)KS?l_3t@QNro0`v!eKMWHE*N+@~eP93j z^L5E{T-a^gut4#f42D}zRuc`^C6^l2v%W~l$kgxb$anYj6ciQ)z;Aq=mj@$_bQKj9 zV2f_H6pJhSmxf8x&q9*xo%28<1>98w!LZOqb5bIL-tXU0HAZ0xzZiBBM84tKS<07} z$rCR;ANV^#Z5P z6aMjE+qQkAUVL~u229v%XmF4nc8|Ao2W=hz_IIYV1$vY%mW8>0c>nzd&M=DtynmlQgTO=g5#o~s33x+MeJov3e{&pHGfm;OI_ic5(kP*8^uB1{2Y30!=WdJ6@nEc?YHK@?IL@113G zygs%(t09tbBB;2U4gPNPf`ghDFQWVp83frZZ{EBaQ_DdeLPs6a(h6Vu`+J-^8XjHX zht%{mCx;4-6!3uQacWLbtRa1P4dNaM5;DOT@%V<}g19Ygn{YvK{rcjcv+`cux3w6e z0gJ%jM@fR-;K2hbbe2Fus*Q9vdzZhuF*cQ&eRUWb-1T{Lj@S72d2H7b& zH5IaC(6`P1&l#i|=qcgPg3k)J65lfu+%$$9iD-HVDv%j0t$z&%fHS9x?*N6M3O$bG zy7PgfnGB7@4bRGA@li{*X@IH*5xFN&UtNmFYpIpU^3vWIt8 zXA1w{ss7zCwg~U{Ze|7Uks(1^6sv5#1g=&=A8dO%mFW$b+Zo` z=P|(#yb#i#ku(WL3kd%DnU%UZk9vnvR-v>(xTJiaR34^Bbj)aOb{Ad=e){Zmy;YuR zspnrO*acpXuXM-sSg0DW_QRcjfq9x%`Q_X14lOdZ^J`-TP3-kZN=AP>CFK){Rf#03 z0M4%`Zr2)NS5fp!VIipnWUr6@=ud00jDnwYnY58j0G0ELi-VzIMMfw+puOL{!v(zK z40!iM#P5GA7i#;ZukRid?sZ>z$baX_?*Q{DbPJ<604xzzCDA9$6Yoomr>QM4Ry9rB zOw3e>ZBZ-OmXVP`JA%Ri5CI(FASEs~c6P|YQ(eUWiU?`I3yJ_vbS);pUo@J6>E+7_~7!_@rwK^z>a{A)R#)qQp)$u5EZoC^DD^9a}1mU8rbajr8L;qtkx zFQLlZ&@0u$D*zRj`d$yR2VTiJo)B9y<*Qfxw~16ONZq|WZ#`0dka*5f~+e__z;asEykcMMg zc;Ta1eRi;!XT%bNx@=VrKP14Q4gw^vJPzN#;tbVo_=}C9RwLY*N=DBa11b@5T2m5`*zR^E4wuKikBI*MtCamDfP!tqk+k*_H$h-fkm>*iZ zr zw#A1JFZGQElq%=9_iaFl5b}s#5lH%2H{zr=nb=~5+ZU9QL zDb&FgZ`ANYjYZdU+!IKRS|WKscU66V?>^Mq+P#ZDmx=cdN@jd z0g$t|_hI|J3e^?F^!VXmLVx@>Z6li@SmSRFlCaEBNB$OcsOf-T;GYdenBbr7B$9a9 z2If5Ua&*;?ftKf`>r~Wt-%Z+2WG9lWBk{u7CECwLL5^HAHG*N#gL;*ITQ z;WHY)b4@K$&t+!#d0K_T{~OqXN|r}!N+Jo_H;nhg@&qMc;17fzjP1}N%)Jrh_~+bWr+6pAUpo`6MUy- z*;U0^RCxEFK6VfmM_HVu5o!WJ4Cwq&Wr%D7EC5L=V>i*|j*wRK4OH~Sr9YSsdsG|; zI|Q0}1TQ+^N@x+H4^u%vER%2bV3jFXvDeDn(f1tvW(?5%V5m$Bi;7w}I?~)xRCM{c zhmTJ_qHTL!y)@r?p!(5g`{HYSPy69iiiS55z)yD9V*$PFvifYYd>}pGxIobXk_X=V zjH>FsO5?oWgJX+ob#g`PMPmlS@v;;}gYg@>fkL#NRyZ97%OZW=Sao$=Edvo8f7o%@ z;Op0~tMSKn`WW{`X=Y_6Q+x;y@Dj=QrMkfNj)4qWJcY3q#4uEt%+>QM>IG-e zIcPL3^MUw8cpX^DtTcZXN1uTKK!nt3?fbB7jNgk`aTdy{lmAbhw7nZjAm{NMq&&Ci zg^sEgl`vztCExXUmxMMx{qBnLaxln*fBK~9=HYR!?rqydjPP9L`KreTrHpzp()=Hm z<2TLE5vT7gv=Z?5F3)f92K$B*7*GfayIL{r2|xXJ>j{zB2Z4@2VmEeXU_3=<3kkcWsL2t`-}|MD#Gl2R#}=&`c1DWGToKtBsiwPl&3qaz%{3d@}9N;0AiCx~%* zf~Ktjx|w~qZ#{Hj5f>A-3%VHA%oApAoJ%P0hQFd@s!o%*A1EN`r+661N1*)xmH@yn z^y$ii40SCnWB>+I1Mo^G5kO2e6baE2z6)=(GbaBWnB3L*n4JFZrv)qZ2f+I?HRaY(IZYxMZI42_3#L>}Y3T_7 z#tJu;NXooabV;z`cGpJ+tO|p?Y#qvR9v)Xo;SpA6-e)htT4^kbt`&xG{3|Y`S5&Y9 zlcQZIQ}I1@&3xsjKC5+6*@(aB;RCWaxsoOOAH|cKh+T4gbD3W-E9F{!-9d68NH>&DtOfu9AdgVy_u@LHf}7xh&AmabSJto0&GoR)PwiqV z0shZ$-<(I-NyrH|Xvdi+k1~cc=qSLL0+1`}z4JivUB8}XS(ecAvUFh1oyq@|%G3VU zMpKK<*A_ZAU!96L*OMsez>q}vz~#aJF?Ai#Sh(%`HL@Cnh(v=jGb$}RDX+?2uWTVB zMZ;c^Qc^OK5<+GOm5`M(k`ziqWR{9ZWPaDf?|;t!9OpZybG{$%`;7a(?(4el`)RT- zJT=b~+$U_+w$x-pH9ie)0|p$gU%kx6#|H(*8Uhkty;^|;i!47HwduhLCM-$ydzn1{ zgr@W0VCJ$r%Qz@VFXRKBAc_2PSo6SMFfn9e3{(Si(5MscBGDaNtQnS zeB7;M(z`RubZPgM`B@ik>o=l<+t0X%F8>l6d&ZQn(*Ci(q~!jGlxVANI^5p^6UpZ5 zCsBxO+SR4Pw!_L|NsD9AbBBF6+uXr@_pF^YR){esF;h_2p7iy_2UBIE z0AK93mQNr-7?>8s6dAt;=2P#1V&1!@kuT1HeMH*G^i6ECcV-LPr|q=1eUq|aXxp*< z$8x@J_xDW~H~#zM)<%M&C53~{%R zoaI2~iaQ9#&>+a9=lK|J`Ywh*e1dOP7C(g*32L~2gARs+@Q@fLg&GBC%d^}9Ke(j` z8~9YH%pM=PuRvGP5%t}W%l=fd1j(;=i*TXMGIL8(;<4ElBDGJQ=n0iKnkzNj-54993xqqL`+d?t9T?wVZh3EUZ_aVj; z>5B-`_`~z3pVD{v4f_b;;=Q!r3ZIVF_M?a(2873kff%%U_rx^Tj2RmU_)S7tUY>UN z3kM@#RKf1QC&p7Pc+TX$a9*Wds`sj})wxaF_Pu1j-d%tKFjrAZy!%!nPyo=7Siw)0 zY}c-9(F{4Cy8r%_2M4983hz@g+I)nFuV5h2XGNsalw;=@PoTEvWX-=#f&1}|@c)A* z7fAFVLR_FG|NdPZ0nhv}LUT&u_N|uqN`z`>bz$jjDYYof?!iH!H_%*`3lK9pd zq^84B^)i|P-AQ5#(gQNsBHB6k`FL1|OZxknO{%|ES}|VomS9T&=-+hx+bx^)_M)%v zAJHF_MJ(gEREssmPyVAvVPJE?5PlsTJdmZAUr?ahNe|d2fVz>C^H9VE{r>0@9T+|m zB+2t1f1aT2!hw-ysp$;ZdB z2MMLZt_wajsNOD$I{0ta9|lK!cw0G%F~PqnjMi-= zaR(gFYj`_Ry{f4N2m&pj@u7IR=W<0K&vZR8F-#LV|D^7VdqLj15V?IB`I-hA8k_4i}$ErhWrw5yAY z6+S7(+YnY+W_7B2D1PZJfH}gHdwbI!{Aqsql8^$}yrX@Ioa(v)4EgD&!;Wm(T%!Bn zeYTz4MIZ&0b%FSSuJIo?sHYaIBN_tp8K-vqI+hJW_ugJaolQ)f zW$2xl_CgcM+BF2&(QaagfISSvLm`?0elVU>#kOzYU?G#WK7RZNpg`{bG7H3IZ|lMi z;o&wg!2}3Gp(3U`1nJ$occ(sk+qdCDis#zOCigX6tLQTgsNRlnZ)zqy!RmqkAT%R( zXE6vzUkF`yAsFjKY%)+@fntb8jgAP&aAbVZ{rmUJOg3k?QGZW`Sl8JXM)9tlme7uV z(OEcrXcHHdB}Ub&)ZY(=JLG?q8i+%^N*#7DA=IW^{K3Q&rMO0bI&cDFzsxh~gG9q+ z;t#%2L{*KMyNH>Ls&&(^oQhG!t8(uH;QQtiYa8@)DFyZ3cZLAT!j5IS+#*ZXIwyRI zEWbB>GlO@=F9r+?t}f3MLXK$~&z(k5Gd>Pd0Z6ZrT_cCli#wI$mj3as8mqt3G#RZb~QT zoN0o~9XmPJ>-8Zx6cEx5Zn_>fd#mreW}049mvlI|zmU|`RJ;XXn}#VSnD)$yI>@_=qC=n@73;kDo{Um?@!gU7Bl_NC5x zEuovYxl`bPiuybRD23p~qFrD`m_ge)zvtW+qlE%R8GW{32T_3$F0+l9+(XExiaY zYr3z#YQ#19cB&dBb+}-AKuWRogs9fdrfB+{sY)1NQti$GSLv;$Q{`xisI>`d9ys@+ zNA>L49Yjq3&!PkAk7)bebRE@R zlwrcWGHB}fgZ+Ll7-#L|#8@Y>H;A?fWKk!YZM^$peeL`5xcfU_g;@OUS*~zlv%Qgf z34LHyY42uER+eB2!uyGx%Ww&R-o==|-`;My^oQ?_BscI^Cw8Lj7a7_!=3@16&+Ws< z{3@dv0F*!h-rVRLyz$>o@rX-D_$vG9hPOX^etSH|YYAc;kZPtHi#`YHN=@s^r;lj4 z3rwU-K)SG11H<3jNY1r3*P_?|x#fI((!m^{5H}grErBOk4FXT3rF-D4!1qA)%Mw;I z{!3n!V-;ZcCb4K{R1}N7XGB>>n1B#OR-2ihYts4Ry6~_2>1LFR6*AsL%6YijbyEn5 z3Z3G^hnuM!giV#?x&DctHCSc~;2(&Q>qWN-4dWC&HS4d*XZ7e~eN4s0uFr4dt(rql z^#?cSfctg$uYL@x!BLGGPZXovFuY?L*7N&u@BL;*pSirm>T;f5dI2|C<0*^W) z6PHj%B|k?n4PR+~wby@3*+-?wPt}&0NjcFz$vPEYOP3Y?DGW&oUPl)hCnY> z$la!=ZgApYuEq5ejNDIKCzr?2A7h0a)V$eSUS4SC<_^I~)YjH06=AXY_nm&E@E@x>n=>8U=lIzKwv)d)Ynv8Uk=F%c~-N_;eMfd zPi5&txuC?7&KEtu6{?gVm2?y$y#M)!znaJyX!_SH`zfeI(114W@%wh-mZ}KMioFphV1j7mZx{YWgCC)**MmeJ}W_2lv;>^5C@~9{zhEBd_Xh1!{e5h zFJH@BErdg3%8W(%p6V{Hjj}tc48%ngSCio7>nnNG!ravKngvA7Z>M%QmtS4SGHZQ& z3cn1B4gkL>EZO466tJw&XT=mnkDYXKnT)y*i4{BzYSU{6)ZMe$!)lQ5qxuq$R@=8{ z0={ZaYU7TECZ4S@zGONe^=0(OANQf1s;lY#1>ZL@3BLJ`L4RFc`7-1KNgl0439&nI zNsF}=G04D>^P*?hz@aYp65&Y32Hn@5G9u2nE}&A#T`4FCLgXM;-p0m89PMk|g}r=Q z98=X%)lWVjC%ca6e3;DnHUz%7zRi zdG^Yzj{cs1KR1{7`jHh-&#LVdcqMULokRUw{!R-Zg*(k*@KjJmsDP8Hp6Sri#LUD@ zCnFey&SlvZDDT=;e<{IX%fBD1SQr7YQ-7L{D)xVu<{gYrvPIEs8ts z+($p=JGg?6b!Qb25U_${0Us6mVkN}0f|3$3!zIZMlB&^6`p>)-1j%?gD1gL%g1!yV z5vf0BB-sycG>%xQt~;Yhsn8p~;uxeT8ZUCOBu#EYr(eFdAz!9BkN{UVx1JwAaysv+ zaGVcQtOMvH_y%M3sJ{-qtj#Pey#94!Iirwsv&}b#pulLWn6?}xus4{=m6+cg!ltAu zIG-4kDsfu2{>bF9l~j(t^}Nf9d!?+{Oy=@t=0Tf?@wK7AMeUPXE7fSwRK}c99rSc$ zm(=74N7|cPPHIc^v*enk((4f6YM}-g85^I3g_2lPQ7=|DbcN81qwM?n3vp1!0RYp6f~3vxv|S9A(}i zFYjngE3k(D|fqRsCX9KoCA^Cj#mGncusqVtWl{1O1m$ z_gZ`l=3O^AkXQC2oyK{v(nfVOtU(O7C1Qc-HU0oCj2?@f2VSFlx z!!es5@q3g!rtR(w1x4uv0yc%kh!;U(38x1KhATnt9##%w2I{$};+GJjk3Z64U7Bs) z_@qTNm9fjZB!AK&pH{mp%{KqEOm=0pyF)I#BDi%Be~4WTLf~md z2?$}tkb+-qk!Re{+svKp^7CU(v$QIAEqo%$-e=X_-tUbyee#}u7o=;!$R3zq8Xi1> zt_O7<75`Us{~r*nKL>4?J>^htic(3P(d{8t(<#gA_;>`40k=~CDb)hUmZH!mN#0zcB%`V zx1k2wI2tm^3{&hmZ1FC+UKLR{A( zFoJVPB*xC%mknQX)RZHUwHzpAd7W!B<$Z5E^6aRMvMI=EXJ%$b0URg3jHCjT*#`Dk z9ZjHs5sSfpgQ>hu9?DT?HM@N6b)Ha)C_sG|C_|bfc~%D^>(J29&Ye52O8(6>5SM09 zZu8m1lA;TPl^)Ybo6QZI66ouAA#btd6$Lx@AK3)LFs`vJx?-PGwQ^&Oo})75X6`&~ zsD#-}bE5Q11iEz>c|EbjJF`-?jp=+GP`FlB zPTht4|YEZHqwK07_UK=C@ zICLlwlSFLo>Pm{fk*{0M6wWⅅcnB%Y5HoR!<4L*xVq(?7&=tbCi>lV}+7|w40&X z2a<2r8{Xgy9^!T*FJJV8`45J5{q{^%&!mh}^G3O~syie}2(qL!Xt4_dv*{~x}`1>sgc;ZXPs zm%q#joq+j^-a{+!O{B{n_yty6z#)9L)MYa+E7J_-u7E(-dA?At!F#EBzvRb6BWg_8 z5(!HH9|8-{5K;!>V9j+LBsE%K{(E90!1*hjV`{gvCe4!(|&PF zW%P)V^9O8;or*e-T6YyV0;Jgu6Xq$K2t$a259a`RRTYJu2yX*OcaQtCO*0&CuYqC}dQeSP{2Jgc-s64%yjS zY#!Oh2ZDIIEv1-M6a(@QXF;>X#G#r&y+C|qpe>0T03-$(nkOy&E;o-c891bBsdQOj zWWY=h!X+(nU`K^u|9qCAhLe#b<)-xeEHw}m-#clNJQq?}K*b;k_PX!F-|R;8(mP5Q zyT{7bmI2N}6AO#y_!C~t98ctMlF1|@m}A3}|4b9UQSbTD*sB+{?cV%gFL1a1r@Ugt ziqWiK*m&1-v#no{mU?i?0kx)nQ$!_{@P(zi%-diPOKBM_8TCR52mrBI0HdhekIIbj z|HLV?I0=S~1z!L`r3T3=k#l0EE8>q<+`!~=I-TJ!F6lBW_pL-oqs^Z=eVQ;3arMtD_vzAv7_OF;d0nc2{{2f)WS_vi`BtN?z!gWBWK6Re z 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

mO5nFdHp@a!8aJh&;3 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 uyesD?pFYLGkfQ Date: Sun, 22 Feb 2026 16:23:35 +0100 Subject: [PATCH 03/68] s --- agents.md | 8 +- ...260222_0016_add_publication_identifiers.py | 104 ++++++ app/api/routers/admin_dbops.py | 13 + app/api/routers/publications.py | 21 +- app/api/schemas.py | 12 + app/db/models.py | 48 +++ app/services/domains/crossref/application.py | 127 ++++++- app/services/domains/ingestion/application.py | 12 +- .../publication_identifiers/__init__.py | 17 + .../publication_identifiers/application.py | 351 ++++++++++++++++++ .../publication_identifiers/normalize.py | 76 ++++ .../domains/publication_identifiers/types.py | 30 ++ app/services/domains/publications/listing.py | 15 +- .../domains/publications/pdf_queue.py | 39 +- .../publications/pdf_resolution_pipeline.py | 150 ++++++++ app/services/domains/publications/types.py | 3 + .../domains/scholar/publication_pdf.py | 232 ++++++++++++ app/services/domains/scholar/rate_limit.py | 18 + app/services/domains/scholar/source.py | 10 + app/services/domains/unpaywall/application.py | 36 +- frontend/src/components/ui/AppHelpHint.vue | 4 +- frontend/src/features/admin_dbops/index.ts | 9 + frontend/src/features/publications/index.ts | 9 + .../features/settings/SettingsAdminPanel.vue | 10 +- frontend/src/pages/PublicationsPage.vue | 18 +- tests/unit/test_crossref_lookup.py | 32 +- tests/unit/test_publication_identifiers.py | 38 ++ .../unit/test_publication_pdf_queue_policy.py | 63 ++++ ...est_publication_pdf_resolution_pipeline.py | 153 ++++++++ tests/unit/test_scholar_publication_pdf.py | 76 ++++ tests/unit/test_unpaywall_resolution.py | 44 ++- 31 files changed, 1725 insertions(+), 53 deletions(-) create mode 100644 alembic/versions/20260222_0016_add_publication_identifiers.py create mode 100644 app/services/domains/publication_identifiers/__init__.py create mode 100644 app/services/domains/publication_identifiers/application.py create mode 100644 app/services/domains/publication_identifiers/normalize.py create mode 100644 app/services/domains/publication_identifiers/types.py create mode 100644 app/services/domains/publications/pdf_resolution_pipeline.py create mode 100644 app/services/domains/scholar/publication_pdf.py create mode 100644 app/services/domains/scholar/rate_limit.py create mode 100644 tests/unit/test_publication_identifiers.py create mode 100644 tests/unit/test_publication_pdf_resolution_pipeline.py create mode 100644 tests/unit/test_scholar_publication_pdf.py diff --git a/agents.md b/agents.md index 07b62d2..da59b76 100644 --- a/agents.md +++ b/agents.md @@ -7,6 +7,7 @@ Adhere strictly to these constraints. * **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. +no magic numbers ## 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. @@ -29,13 +30,6 @@ These limits prevent IP bans and are not to be optimized away. ## 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. ## 6. UI rules 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..cd824a2 --- /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 + +from alembic import op +import sqlalchemy as sa + + +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/app/api/routers/admin_dbops.py b/app/api/routers/admin_dbops.py index 16271a0..cd6d924 100644 --- a/app/api/routers/admin_dbops.py +++ b/app/api/routers/admin_dbops.py @@ -58,6 +58,7 @@ def _serialize_pdf_queue_item(item) -> dict[str, object]: "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 +74,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." diff --git a/app/api/routers/publications.py b/app/api/routers/publications.py index 5055c6c..ad8fc55 100644 --- a/app/api/routers/publications.py +++ b/app/api/routers/publications.py @@ -21,6 +21,7 @@ from app.api.schemas import ( ) from app.db.models import User from app.db.session import get_db_session +from app.services.domains.publication_identifiers import application as identifier_service from app.services.domains.publications import application as publication_service from app.services.domains.scholars import application as scholar_service from app.settings import settings @@ -62,6 +63,7 @@ def _serialize_publication_item(item) -> dict[str, object]: "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,6 +76,18 @@ 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, *, @@ -280,7 +294,12 @@ async def _favorite_publication_state( db_session, items=[publication], ) - return hydrated[0] if hydrated else publication + 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 def _log_retry_pdf_result( diff --git a/app/api/schemas.py b/app/api/schemas.py index 3ae8876..112f672 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -581,10 +581,21 @@ class AdminDbRepairJobsEnvelope(BaseModel): model_config = ConfigDict(extra="forbid") +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 AdminPdfQueueItemData(BaseModel): publication_id: int title: str doi: str | None + display_identifier: DisplayIdentifierData | None = None pdf_url: str | None status: str attempt_count: int @@ -744,6 +755,7 @@ class PublicationItemData(BaseModel): venue_text: str | None pub_url: str | None doi: str | None + display_identifier: DisplayIdentifierData | None = None pdf_url: str | None pdf_status: str = "untracked" pdf_attempt_count: int = 0 diff --git a/app/db/models.py b/app/db/models.py index 43b44c2..1f0b250 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, @@ -237,6 +238,53 @@ class Publication(Base): ) +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", + ), + ) + + 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): __tablename__ = "publication_pdf_jobs" __table_args__ = ( diff --git a/app/services/domains/crossref/application.py b/app/services/domains/crossref/application.py index 569c12d..f0e1311 100644 --- a/app/services/domains/crossref/application.py +++ b/app/services/domains/crossref/application.py @@ -21,6 +21,8 @@ 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: @@ -132,7 +134,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 +186,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,6 +203,92 @@ 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) diff --git a/app/services/domains/ingestion/application.py b/app/services/domains/ingestion/application.py index 54a7e25..4cb493d 100644 --- a/app/services/domains/ingestion/application.py +++ b/app/services/domains/ingestion/application.py @@ -29,6 +29,7 @@ from app.services.domains.ingestion.constants import ( RUN_LOCK_NAMESPACE, ) from app.services.domains.doi.normalize import first_doi_from_texts +from app.services.domains.publication_identifiers import application as identifier_service from app.services.domains.ingestion.fingerprints import ( _build_body_excerpt, _dedupe_publication_candidates, @@ -2071,15 +2072,24 @@ class ScholarIngestionService: fingerprint_publication=fingerprint_publication, ) if publication is None: - return await self._create_publication( + created = await self._create_publication( db_session, candidate=candidate, fingerprint=fingerprint, ) + await identifier_service.sync_identifiers_for_publication_fields( + db_session, + publication=created, + ) + return created self._update_existing_publication( publication=publication, candidate=candidate, ) + await identifier_service.sync_identifiers_for_publication_fields( + db_session, + publication=publication, + ) return publication def _resolve_run_status( diff --git a/app/services/domains/publication_identifiers/__init__.py b/app/services/domains/publication_identifiers/__init__.py new file mode 100644 index 0000000..3a35c66 --- /dev/null +++ b/app/services/domains/publication_identifiers/__init__.py @@ -0,0 +1,17 @@ +from app.services.domains.publication_identifiers.application import ( + DisplayIdentifier, + derive_display_identifier_from_values, + 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", + "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/domains/publication_identifiers/application.py b/app/services/domains/publication_identifiers/application.py new file mode 100644 index 0000000..d34e757 --- /dev/null +++ b/app/services/domains/publication_identifiers/application.py @@ -0,0 +1,351 @@ +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.domains.doi.normalize import normalize_doi +from app.services.domains.publication_identifiers.normalize import ( + normalize_arxiv_id, + normalize_pmcid, + normalize_pmid, +) +from app.services.domains.publication_identifiers.types import ( + DisplayIdentifier, + IdentifierCandidate, + IdentifierKind, +) + +if TYPE_CHECKING: + from app.services.domains.publications.pdf_queue import PdfQueueListItem + from app.services.domains.publications.types import PublicationListItem + +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) + + +def _publication_field_candidates(publication: Publication) -> list[IdentifierCandidate]: + return _fallback_candidates_from_values( + doi=publication.doi, + 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() + + +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=item.doi, 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=item.doi, pdf_url=item.pdf_url) + return replace(item, display_identifier=fallback) + + +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/domains/publication_identifiers/normalize.py b/app/services/domains/publication_identifiers/normalize.py new file mode 100644 index 0000000..50036be --- /dev/null +++ b/app/services/domains/publication_identifiers/normalize.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import re +from urllib.parse import urlparse + +from app.services.domains.doi.normalize import normalize_doi +from app.services.domains.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_PATH_RE = re.compile(r"^/(?:abs|pdf)/([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) + 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/domains/publication_identifiers/types.py b/app/services/domains/publication_identifiers/types.py new file mode 100644 index 0000000..34b20d0 --- /dev/null +++ b/app/services/domains/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/domains/publications/listing.py b/app/services/domains/publications/listing.py index f4f518b..0d09d4c 100644 --- a/app/services/domains/publications/listing.py +++ b/app/services/domains/publications/listing.py @@ -2,6 +2,7 @@ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession +from app.services.domains.publication_identifiers import application as identifier_service from app.services.domains.publications.modes import ( MODE_ALL, MODE_UNREAD, @@ -44,7 +45,10 @@ async def list_for_user( publication_list_item_from_row(row, latest_run_id=latest_run_id) for row in result.all() ] - return rows + return await identifier_service.overlay_publication_items_with_display_identifiers( + db_session, + items=rows, + ) async def retry_pdf_for_user( @@ -54,12 +58,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/pdf_queue.py b/app/services/domains/publications/pdf_queue.py index b420d46..3972f7d 100644 --- a/app/services/domains/publications/pdf_queue.py +++ b/app/services/domains/publications/pdf_queue.py @@ -17,11 +17,16 @@ from app.db.models import ( User, ) from app.db.session import get_session_factory +from app.services.domains.publication_identifiers import application as identifier_service +from app.services.domains.publication_identifiers.types import DisplayIdentifier +from app.services.domains.publications.pdf_resolution_pipeline import ( + PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE, + resolve_publication_pdf_outcome_for_row, +) 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 @@ -57,6 +62,7 @@ class PdfQueueListItem: last_attempt_at: datetime | None resolved_at: datetime | None updated_at: datetime + display_identifier: DisplayIdentifier | None = None @dataclass(frozen=True) @@ -118,6 +124,7 @@ def _item_from_row_and_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, ) @@ -366,8 +373,11 @@ 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) + pipeline_result = await resolve_publication_pdf_outcome_for_row( + row=row, + request_email=request_email, + ) + outcome = pipeline_result.outcome if outcome is not None: return outcome return _failed_outcome(row=row) @@ -417,6 +427,11 @@ async def _persist_outcome( if publication is None or job is None: return _apply_publication_update(publication, doi=outcome.doi, 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( @@ -794,6 +809,18 @@ def _queue_item_from_row(row: tuple) -> PdfQueueListItem: ) +async def _hydrated_queue_items( + db_session: AsyncSession, + *, + rows: list[tuple], +) -> 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, + ) + + async def list_pdf_queue_items( db_session: AsyncSession, *, @@ -811,7 +838,7 @@ async def list_pdf_queue_items( offset=bounded_offset, ) ) - return [_queue_item_from_row(row) for row in result.all()] + return await _hydrated_queue_items(db_session, rows=list(result.all())) if normalized_status is None: result = await db_session.execute( _all_queue_select( @@ -819,7 +846,7 @@ async def list_pdf_queue_items( offset=bounded_offset, ) ) - return [_queue_item_from_row(row) for row in result.all()] + return await _hydrated_queue_items(db_session, rows=list(result.all())) result = await db_session.execute( _tracked_queue_select( limit=bounded_limit, @@ -827,7 +854,7 @@ async def list_pdf_queue_items( status=normalized_status, ) ) - return [_queue_item_from_row(row) for row in result.all()] + return await _hydrated_queue_items(db_session, rows=list(result.all())) async def count_pdf_queue_items( diff --git a/app/services/domains/publications/pdf_resolution_pipeline.py b/app/services/domains/publications/pdf_resolution_pipeline.py new file mode 100644 index 0000000..bc41257 --- /dev/null +++ b/app/services/domains/publications/pdf_resolution_pipeline.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from dataclasses import dataclass +import logging +from urllib.parse import urlparse + +import httpx + +from app.services.domains.publications.types import PublicationListItem +from app.services.domains.scholar.publication_pdf import ( + ScholarPublicationLinkCandidate, + ScholarPublicationLinkCandidates, + fetch_link_candidates_from_scholar_publication_page, +) +from app.services.domains.unpaywall import pdf_discovery as pdf_discovery_service +from app.services.domains.unpaywall.application import OaResolutionOutcome, resolve_publication_oa_outcomes +from app.settings import settings + +logger = logging.getLogger(__name__) + +PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE = "scholar_publication_page" +PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE_UNLABELED = "scholar_publication_page_unlabeled_fallback" +PDF_PATH_TOKEN = "/pdf/" +HTTP_TIMEOUT_FLOOR_SECONDS = 0.5 + + +@dataclass(frozen=True) +class PipelineOutcome: + outcome: OaResolutionOutcome | None + scholar_candidates: ScholarPublicationLinkCandidates | None + + +async def resolve_publication_pdf_outcome_for_row( + *, + row: PublicationListItem, + request_email: str | None, +) -> PipelineOutcome: + candidates = await _safe_scholar_candidates(row.pub_url) + labeled = _labeled_candidate(candidates) + if labeled is not None: + return PipelineOutcome(_scholar_outcome(row=row, candidate=labeled), candidates) + oa_outcome = await _oa_outcome(row=row, request_email=request_email) + if _oa_has_pdf(oa_outcome): + return PipelineOutcome(oa_outcome, candidates) + unlabeled = _unlabeled_candidate(candidates) + if unlabeled is None: + return PipelineOutcome(oa_outcome, candidates) + fallback_outcome = await _unlabeled_fallback_outcome(row=row, candidate=unlabeled) + if fallback_outcome is not None: + return PipelineOutcome(fallback_outcome, candidates) + return PipelineOutcome(oa_outcome, candidates) + + +async def _safe_scholar_candidates(pub_url: str | None) -> ScholarPublicationLinkCandidates | None: + try: + return await fetch_link_candidates_from_scholar_publication_page(pub_url) + except Exception as exc: # pragma: no cover - defensive boundary + logger.warning( + "publications.pdf_resolution.scholar_candidates_failed", + extra={"event": "publications.pdf_resolution.scholar_candidates_failed", "error": str(exc)}, + ) + return None + + +def _labeled_candidate( + candidates: ScholarPublicationLinkCandidates | None, +) -> ScholarPublicationLinkCandidate | None: + if candidates is None: + return None + return candidates.labeled_candidate + + +def _unlabeled_candidate( + candidates: ScholarPublicationLinkCandidates | None, +) -> ScholarPublicationLinkCandidate | None: + if candidates is None: + return None + return candidates.fallback_candidate + + +def _scholar_outcome( + *, + row: PublicationListItem, + candidate: ScholarPublicationLinkCandidate, +) -> OaResolutionOutcome: + source = ( + PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE + if candidate.label_present + else PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE_UNLABELED + ) + return OaResolutionOutcome( + publication_id=row.publication_id, + doi=row.doi, + pdf_url=candidate.url, + failure_reason=None, + source=source, + used_crossref=False, + ) + + +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) + + +def _oa_has_pdf(outcome: OaResolutionOutcome | None) -> bool: + return bool(outcome and outcome.pdf_url) + + +async def _unlabeled_fallback_outcome( + *, + row: PublicationListItem, + candidate: ScholarPublicationLinkCandidate, +) -> OaResolutionOutcome | None: + pdf_url = await _validated_pdf_url(candidate.url) + if pdf_url is None: + return None + return _scholar_outcome(row=row, candidate=ScholarPublicationLinkCandidate( + url=pdf_url, + confidence_score=candidate.confidence_score, + label_present=False, + reason=candidate.reason, + )) + + +async def _validated_pdf_url(candidate_url: str) -> str | None: + if _looks_direct_pdf(candidate_url): + return candidate_url + timeout_seconds = _discovery_timeout_seconds() + async with httpx.AsyncClient(timeout=timeout_seconds) as client: + if await pdf_discovery_service._candidate_is_pdf(client, candidate_url=candidate_url): + return candidate_url + return await pdf_discovery_service.resolve_pdf_from_landing_page(client, page_url=candidate_url) + + +def _looks_direct_pdf(url: str | None) -> bool: + if pdf_discovery_service.looks_like_pdf_url(url): + return True + if not isinstance(url, str): + return False + path = (urlparse(url).path or "").lower() + return PDF_PATH_TOKEN in path + + +def _discovery_timeout_seconds() -> float: + return max(float(settings.unpaywall_timeout_seconds), HTTP_TIMEOUT_FLOOR_SECONDS) diff --git a/app/services/domains/publications/types.py b/app/services/domains/publications/types.py index d1d4bad..3bde82d 100644 --- a/app/services/domains/publications/types.py +++ b/app/services/domains/publications/types.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime +from app.services.domains.publication_identifiers.types import DisplayIdentifier + @dataclass(frozen=True) class PublicationListItem: @@ -24,6 +26,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) diff --git a/app/services/domains/scholar/publication_pdf.py b/app/services/domains/scholar/publication_pdf.py new file mode 100644 index 0000000..6f8b6c1 --- /dev/null +++ b/app/services/domains/scholar/publication_pdf.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from dataclasses import dataclass +from html.parser import HTMLParser +from urllib.parse import parse_qs, urlparse + +from app.services.domains.scholar.parser_types import ScholarDomInvariantError +from app.services.domains.scholar.parser_utils import attr_href, normalize_space +from app.services.domains.scholar.rate_limit import wait_for_scholar_slot +from app.services.domains.scholar.source import LiveScholarSource +from app.services.domains.settings.application import resolve_request_delay_minimum +from app.settings import settings + +CONTAINER_ID = "gsc_oci_title_gg" +PDF_LABEL_TOKEN = "[pdf]" +SCHOLAR_PDF_LABELED_CONFIDENCE = 0.98 +SCHOLAR_PDF_UNLABELED_CONFIDENCE = 0.2 +ALLOWED_URL_SCHEMES = frozenset({"http", "https"}) + + +@dataclass(frozen=True) +class ScholarPublicationLinkCandidate: + url: str + confidence_score: float + label_present: bool + reason: str + + +@dataclass(frozen=True) +class ScholarPublicationLinkCandidates: + container_seen: bool + labeled_candidate: ScholarPublicationLinkCandidate | None + fallback_candidate: ScholarPublicationLinkCandidate | None + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True) +class _ParsedAnchor: + href: str | None + text: str + + +class _ScholarPublicationPdfParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.container_seen = False + self.anchors: list[_ParsedAnchor] = [] + self._container_depth = 0 + self._anchor_depth = 0 + self._anchor_href: str | None = None + self._anchor_parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + self._increment_depths() + if self._starts_container(tag, attrs): + self.container_seen = True + self._container_depth = 1 + return + if self._container_depth <= 0 or tag != "a": + return + if self._anchor_depth > 0: + return + self._anchor_href = attr_href(attrs) + self._anchor_parts = [] + self._anchor_depth = 1 + + def handle_data(self, data: str) -> None: + if self._anchor_depth > 0: + self._anchor_parts.append(data) + + def handle_endtag(self, tag: str) -> None: + if self._anchor_depth > 0: + self._anchor_depth -= 1 + if self._anchor_depth == 0: + self._finish_anchor() + if self._container_depth > 0: + self._container_depth -= 1 + + def _increment_depths(self) -> None: + if self._container_depth > 0: + self._container_depth += 1 + if self._anchor_depth > 0: + self._anchor_depth += 1 + + def _starts_container(self, tag: str, attrs: list[tuple[str, str | None]]) -> bool: + if tag != "div": + return False + attrs_map = {name.lower(): (value or "") for name, value in attrs} + return attrs_map.get("id") == CONTAINER_ID + + def _finish_anchor(self) -> None: + self.anchors.append( + _ParsedAnchor( + href=self._anchor_href, + text=normalize_space("".join(self._anchor_parts)), + ) + ) + self._anchor_href = None + self._anchor_parts = [] + + +def is_scholar_publication_detail_url(url: str | None) -> bool: + if not isinstance(url, str) or not url.strip(): + return False + parsed = urlparse(url) + if parsed.scheme not in ALLOWED_URL_SCHEMES: + return False + if parsed.netloc.lower() != "scholar.google.com": + return False + query = parse_qs(parsed.query) + return _has_view_citation_params(query) + + +def _has_view_citation_params(query: dict[str, list[str]]) -> bool: + view_op = (query.get("view_op") or [""])[0] + citation = (query.get("citation_for_view") or [""])[0].strip() + return view_op == "view_citation" and bool(citation) + + +def extract_link_candidates_from_publication_detail_html(html: str) -> ScholarPublicationLinkCandidates: + parser = _parsed_publication_detail(html) + if not parser.container_seen: + return ScholarPublicationLinkCandidates(False, None, None) + anchors = _validated_container_anchors(parser.anchors) + labeled = _select_labeled_candidate(anchors) + fallback = _select_fallback_candidate(anchors, labeled=labeled) + warnings = _candidate_warnings(labeled=labeled, fallback=fallback) + return ScholarPublicationLinkCandidates(True, labeled, fallback, warnings) + + +def _parsed_publication_detail(html: str) -> _ScholarPublicationPdfParser: + parser = _ScholarPublicationPdfParser() + parser.feed(html) + parser.close() + return parser + + +def _validated_container_anchors(anchors: list[_ParsedAnchor]) -> list[_ParsedAnchor]: + if not anchors: + raise ScholarDomInvariantError( + code="layout_publication_link_container_missing_anchor", + message="Scholar publication link container was present without an anchor.", + ) + validated: list[_ParsedAnchor] = [] + for anchor in anchors: + validated.append(_validated_anchor(anchor)) + return validated + + +def _validated_anchor(anchor: _ParsedAnchor) -> _ParsedAnchor: + href = (anchor.href or "").strip() + if not href: + raise ScholarDomInvariantError( + code="layout_publication_link_missing_href", + message="Scholar publication link container anchor was missing href.", + ) + parsed = urlparse(href) + if parsed.scheme not in ALLOWED_URL_SCHEMES: + raise ScholarDomInvariantError( + code="layout_publication_link_invalid_scheme", + message="Scholar publication link used a non-http URL.", + ) + return _ParsedAnchor(href=href, text=anchor.text) + + +def _select_labeled_candidate(anchors: list[_ParsedAnchor]) -> ScholarPublicationLinkCandidate | None: + for anchor in anchors: + if PDF_LABEL_TOKEN in anchor.text.lower(): + return ScholarPublicationLinkCandidate( + url=str(anchor.href), + confidence_score=SCHOLAR_PDF_LABELED_CONFIDENCE, + label_present=True, + reason="scholar_link_labeled_pdf", + ) + return None + + +def _select_fallback_candidate( + anchors: list[_ParsedAnchor], + *, + labeled: ScholarPublicationLinkCandidate | None, +) -> ScholarPublicationLinkCandidate | None: + for anchor in anchors: + if labeled and anchor.href == labeled.url: + continue + return ScholarPublicationLinkCandidate( + url=str(anchor.href), + confidence_score=SCHOLAR_PDF_UNLABELED_CONFIDENCE, + label_present=False, + reason="scholar_link_unlabeled_fallback", + ) + if labeled is None and anchors: + anchor = anchors[0] + return ScholarPublicationLinkCandidate( + url=str(anchor.href), + confidence_score=SCHOLAR_PDF_UNLABELED_CONFIDENCE, + label_present=False, + reason="scholar_link_unlabeled_fallback", + ) + return None + + +def _candidate_warnings( + *, + labeled: ScholarPublicationLinkCandidate | None, + fallback: ScholarPublicationLinkCandidate | None, +) -> tuple[str, ...]: + warnings: list[str] = [] + if labeled is None and fallback is not None: + warnings.append("scholar_publication_link_unlabeled_only") + return tuple(warnings) + + +def _scholar_request_delay_seconds() -> int: + return resolve_request_delay_minimum(settings.ingestion_min_request_delay_seconds) + + +def _fetch_succeeded(fetch_result) -> bool: + return int(fetch_result.status_code or 0) == 200 and not fetch_result.error + + +async def fetch_link_candidates_from_scholar_publication_page( + publication_url: str | None, +) -> ScholarPublicationLinkCandidates | None: + if not is_scholar_publication_detail_url(publication_url): + return None + await wait_for_scholar_slot(min_interval_seconds=float(_scholar_request_delay_seconds())) + source = LiveScholarSource() + fetch_result = await source.fetch_publication_html(str(publication_url)) + if not _fetch_succeeded(fetch_result): + return None + return extract_link_candidates_from_publication_detail_html(fetch_result.body) diff --git a/app/services/domains/scholar/rate_limit.py b/app/services/domains/scholar/rate_limit.py new file mode 100644 index 0000000..947c91f --- /dev/null +++ b/app/services/domains/scholar/rate_limit.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import asyncio +import time + +_REQUEST_LOCK = asyncio.Lock() +_LAST_REQUEST_AT = 0.0 + + +async def wait_for_scholar_slot(*, min_interval_seconds: float) -> None: + global _LAST_REQUEST_AT + interval = max(float(min_interval_seconds), 0.0) + async with _REQUEST_LOCK: + elapsed = time.monotonic() - _LAST_REQUEST_AT + remaining = interval - elapsed + if remaining > 0: + await asyncio.sleep(remaining) + _LAST_REQUEST_AT = time.monotonic() diff --git a/app/services/domains/scholar/source.py b/app/services/domains/scholar/source.py index 83da165..92c6702 100644 --- a/app/services/domains/scholar/source.py +++ b/app/services/domains/scholar/source.py @@ -123,6 +123,16 @@ class LiveScholarSource: ) return await asyncio.to_thread(self._fetch_sync, requested_url) + async def fetch_publication_html(self, publication_url: str) -> FetchResult: + logger.debug( + "scholar_source.publication_fetch_started", + extra={ + "event": "scholar_source.publication_fetch_started", + "requested_url": publication_url, + }, + ) + return await asyncio.to_thread(self._fetch_sync, publication_url) + def _build_request(self, requested_url: str) -> Request: return Request( requested_url, diff --git a/app/services/domains/unpaywall/application.py b/app/services/domains/unpaywall/application.py index 44c7560..a658818 100644 --- a/app/services/domains/unpaywall/application.py +++ b/app/services/domains/unpaywall/application.py @@ -64,20 +64,16 @@ 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 - pub_url_doi = _extract_doi_candidate(item.pub_url) - if pub_url_doi: - return normalize_doi(pub_url_doi) - return ( + 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 stored def _payload_locations(payload: dict) -> list[dict]: @@ -217,30 +213,30 @@ async def _resolve_item_payload( 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 +261,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, @@ -323,7 +320,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 +331,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( 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/features/admin_dbops/index.ts b/frontend/src/features/admin_dbops/index.ts index e3269e1..2f042f5 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; @@ -34,6 +42,7 @@ export interface AdminPdfQueueItem { publication_id: number; title: string; doi: string | null; + display_identifier: DisplayIdentifier | null; pdf_url: string | null; status: string; attempt_count: number; diff --git a/frontend/src/features/publications/index.ts b/frontend/src/features/publications/index.ts index 2eddfbb..f238a0e 100644 --- a/frontend/src/features/publications/index.ts +++ b/frontend/src/features/publications/index.ts @@ -2,6 +2,14 @@ import { apiRequest } from "@/lib/api/client"; export type PublicationMode = "all" | "unread" | "latest"; +export interface DisplayIdentifier { + kind: string; + value: string; + label: string; + url: string | null; + confidence_score: number; +} + export interface PublicationItem { publication_id: number; scholar_profile_id: number; @@ -12,6 +20,7 @@ export interface PublicationItem { 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; diff --git a/frontend/src/features/settings/SettingsAdminPanel.vue b/frontend/src/features/settings/SettingsAdminPanel.vue index 7f5788e..7edf67b 100644 --- a/frontend/src/features/settings/SettingsAdminPanel.vue +++ b/frontend/src/features/settings/SettingsAdminPanel.vue @@ -748,8 +748,14 @@ watch(

diff --git a/frontend/src/pages/PublicationsPage.vue b/frontend/src/pages/PublicationsPage.vue index fbecad9..beeedc7 100644 --- a/frontend/src/pages/PublicationsPage.vue +++ b/frontend/src/pages/PublicationsPage.vue @@ -169,11 +169,15 @@ function publicationPrimaryUrl(item: PublicationItem): string | null { return item.pub_url || item.pdf_url; } -function publicationDoiUrl(item: PublicationItem): string | null { - if (!item.doi) { +function publicationIdentifierUrl(item: PublicationItem): string | null { + if (!item.display_identifier?.url) { return null; } - return `https://doi.org/${item.doi}`; + return item.display_identifier.url; +} + +function publicationIdentifierLabel(item: PublicationItem): string | null { + return item.display_identifier?.label ?? null; } const selectedScholarName = computed(() => { @@ -1043,14 +1047,14 @@ watch( {{ item.title }} - DOI: {{ item.doi }} + {{ publicationIdentifierLabel(item) }} diff --git a/tests/unit/test_crossref_lookup.py b/tests/unit/test_crossref_lookup.py index 5d71281..67712fa 100644 --- a/tests/unit/test_crossref_lookup.py +++ b/tests/unit/test_crossref_lookup.py @@ -52,7 +52,9 @@ async def test_crossref_discovers_doi_from_best_title_match(monkeypatch: pytest. @pytest.mark.asyncio -async def test_crossref_rejects_large_year_mismatch(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_crossref_relaxed_fallback_allows_large_year_mismatch_for_strong_title_match( + monkeypatch: pytest.MonkeyPatch, +) -> None: async def _fake_fetch_items(**_kwargs): return [ { @@ -72,4 +74,30 @@ async def test_crossref_rejects_large_year_mismatch(monkeypatch: pytest.MonkeyPa max_rows=10, email=None, ) - assert doi is None + assert doi == "10.1000/wrong-year" + + +@pytest.mark.asyncio +async def test_crossref_relaxed_fallback_allows_author_mismatch_for_strong_title_match( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_fetch_items(**_kwargs): + return [ + { + "DOI": "10.1000/author-fallback", + "title": ["Induction of Pluripotent Stem Cells from Adult Human Fibroblasts"], + "issued": {"date-parts": [[2007]]}, + "author": [{"family": "SomeoneElse"}], + } + ] + + monkeypatch.setattr(crossref_app, "_fetch_items", _fake_fetch_items) + doi = await crossref_app.discover_doi_for_publication( + item=_item( + title="Induction of Pluripotent Stem Cells from Adult Human Fibroblasts", + year=2007, + ), + max_rows=10, + email=None, + ) + assert doi == "10.1000/author-fallback" diff --git a/tests/unit/test_publication_identifiers.py b/tests/unit/test_publication_identifiers.py new file mode 100644 index 0000000..feab56b --- /dev/null +++ b/tests/unit/test_publication_identifiers.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from app.services.domains.publication_identifiers import application as identifier_service + + +def test_derive_display_identifier_prefers_doi_over_arxiv() -> None: + display = identifier_service.derive_display_identifier_from_values( + doi="10.1000/example", + pub_url="https://arxiv.org/abs/1504.08025", + pdf_url=None, + ) + assert display is not None + assert display.kind == "doi" + assert display.value == "10.1000/example" + assert display.url == "https://doi.org/10.1000/example" + + +def test_derive_display_identifier_uses_arxiv_when_doi_missing() -> None: + display = identifier_service.derive_display_identifier_from_values( + doi=None, + pub_url="https://arxiv.org/pdf/1504.08025v2", + pdf_url=None, + ) + assert display is not None + assert display.kind == "arxiv" + assert display.value == "1504.08025v2" + assert display.label == "arXiv: 1504.08025v2" + + +def test_derive_display_identifier_uses_pmcid_when_present() -> None: + display = identifier_service.derive_display_identifier_from_values( + doi=None, + pub_url=None, + pdf_url="https://pmc.ncbi.nlm.nih.gov/articles/PMC2175868/pdf/file.pdf", + ) + assert display is not None + assert display.kind == "pmcid" + assert display.value == "PMC2175868" diff --git a/tests/unit/test_publication_pdf_queue_policy.py b/tests/unit/test_publication_pdf_queue_policy.py index 3d4fe7f..0cc3a7c 100644 --- a/tests/unit/test_publication_pdf_queue_policy.py +++ b/tests/unit/test_publication_pdf_queue_policy.py @@ -1,11 +1,14 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone +from types import SimpleNamespace import pytest from app.db.models import PublicationPdfJob from app.services.domains.publications import pdf_queue +from app.services.domains.publications.pdf_resolution_pipeline import PipelineOutcome +from app.services.domains.unpaywall.application import OaResolutionOutcome def _job( @@ -22,6 +25,25 @@ def _job( ) +def _row(*, pub_url: str | None = "https://scholar.google.com/citations?view_op=view_citation&citation_for_view=abc:xyz") -> SimpleNamespace: + return SimpleNamespace( + publication_id=1, + scholar_profile_id=1, + scholar_label="Ada Lovelace", + title="A paper", + year=2024, + citation_count=0, + venue_text=None, + pub_url=pub_url, + doi=None, + pdf_url=None, + is_read=False, + is_favorite=False, + first_seen_at=datetime(2026, 2, 22, 12, 0, tzinfo=timezone.utc), + is_new_in_latest_run=True, + ) + + def test_pdf_queue_auto_enqueue_blocks_recent_attempt(monkeypatch: pytest.MonkeyPatch) -> None: now = datetime(2026, 2, 21, 12, 0, tzinfo=timezone.utc) monkeypatch.setattr(pdf_queue, "_utcnow", lambda: now) @@ -107,3 +129,44 @@ def test_pdf_queue_manual_requeue_still_blocks_when_inflight() -> None: ) assert pdf_queue._can_enqueue_job(running, force_retry=True) is False assert pdf_queue._can_enqueue_job(queued, force_retry=True) is False + + +@pytest.mark.asyncio +async def test_fetch_outcome_for_row_uses_pipeline_outcome(monkeypatch: pytest.MonkeyPatch) -> None: + async def _fake_pipeline(*, row, request_email=None): + assert request_email == "user@example.com" + return PipelineOutcome( + outcome=OaResolutionOutcome( + publication_id=row.publication_id, + doi=None, + pdf_url="https://arxiv.org/pdf/1703.06103", + failure_reason=None, + source=pdf_queue.PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE, + used_crossref=False, + ), + scholar_candidates=None, + ) + + monkeypatch.setattr(pdf_queue, "resolve_publication_pdf_outcome_for_row", _fake_pipeline) + + outcome = await pdf_queue._fetch_outcome_for_row(row=_row(), request_email="user@example.com") + + assert outcome.pdf_url == "https://arxiv.org/pdf/1703.06103" + assert outcome.source == pdf_queue.PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE + assert outcome.used_crossref is False + + +@pytest.mark.asyncio +async def test_fetch_outcome_for_row_returns_failed_outcome_when_pipeline_returns_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_pipeline(*, row, request_email=None): + assert request_email == "user@example.com" + return PipelineOutcome(outcome=None, scholar_candidates=None) + + monkeypatch.setattr(pdf_queue, "resolve_publication_pdf_outcome_for_row", _fake_pipeline) + + outcome = await pdf_queue._fetch_outcome_for_row(row=_row(), request_email="user@example.com") + + assert outcome.pdf_url is None + assert outcome.failure_reason == pdf_queue.FAILURE_RESOLUTION_EXCEPTION diff --git a/tests/unit/test_publication_pdf_resolution_pipeline.py b/tests/unit/test_publication_pdf_resolution_pipeline.py new file mode 100644 index 0000000..bc7bb5c --- /dev/null +++ b/tests/unit/test_publication_pdf_resolution_pipeline.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest + +from app.services.domains.publications import pdf_resolution_pipeline as pipeline +from app.services.domains.unpaywall.application import OaResolutionOutcome + + +@dataclass(frozen=True) +class _Candidate: + url: str + confidence_score: float + label_present: bool + reason: str + + +@dataclass(frozen=True) +class _Candidates: + container_seen: bool + labeled_candidate: _Candidate | None + fallback_candidate: _Candidate | None + warnings: tuple[str, ...] = () + + +def _row(*, doi: str | None = None) -> SimpleNamespace: + return SimpleNamespace( + publication_id=1, + scholar_profile_id=1, + scholar_label="Ada Lovelace", + title="A paper", + year=2024, + citation_count=0, + venue_text=None, + pub_url="https://scholar.google.com/citations?view_op=view_citation&citation_for_view=abc:xyz", + doi=doi, + pdf_url=None, + is_read=False, + is_favorite=False, + first_seen_at=datetime(2026, 2, 22, 12, 0, tzinfo=timezone.utc), + is_new_in_latest_run=True, + ) + + +def _oa_outcome(*, pdf_url: str | None, source: str = "unpaywall") -> OaResolutionOutcome: + return OaResolutionOutcome( + publication_id=1, + doi="10.1000/example", + pdf_url=pdf_url, + failure_reason=None if pdf_url else "no_pdf_found", + source=source, + used_crossref=False, + ) + + +@pytest.mark.asyncio +async def test_pipeline_prefers_labeled_scholar_candidate_before_oa(monkeypatch: pytest.MonkeyPatch) -> None: + async def _fake_candidates(_url): + return _Candidates( + container_seen=True, + labeled_candidate=_Candidate( + url="https://arxiv.org/pdf/1703.06103", + confidence_score=0.98, + label_present=True, + reason="scholar_link_labeled_pdf", + ), + fallback_candidate=None, + ) + + async def _fail_oa(*, row, request_email): + raise AssertionError(f"OA should not run when labeled Scholar candidate exists: {row.publication_id} {request_email}") + + monkeypatch.setattr(pipeline, "fetch_link_candidates_from_scholar_publication_page", _fake_candidates) + monkeypatch.setattr(pipeline, "_oa_outcome", _fail_oa) + + result = await pipeline.resolve_publication_pdf_outcome_for_row(row=_row(), request_email="user@example.com") + + assert result.outcome is not None + assert result.outcome.pdf_url == "https://arxiv.org/pdf/1703.06103" + assert result.outcome.source == pipeline.PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE + + +@pytest.mark.asyncio +async def test_pipeline_uses_oa_result_before_unlabeled_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + async def _fake_candidates(_url): + return _Candidates( + container_seen=True, + labeled_candidate=None, + fallback_candidate=_Candidate( + url="https://example.org/download/42", + confidence_score=0.2, + label_present=False, + reason="scholar_link_unlabeled_fallback", + ), + ) + + async def _fake_oa(*, row, request_email): + assert request_email == "user@example.com" + return _oa_outcome(pdf_url="https://oa.example.org/found.pdf") + + async def _fail_fallback(*, row, candidate): + raise AssertionError(f"Unlabeled fallback should not run when OA returns PDF: {row.publication_id} {candidate.url}") + + monkeypatch.setattr(pipeline, "fetch_link_candidates_from_scholar_publication_page", _fake_candidates) + monkeypatch.setattr(pipeline, "_oa_outcome", _fake_oa) + monkeypatch.setattr(pipeline, "_unlabeled_fallback_outcome", _fail_fallback) + + result = await pipeline.resolve_publication_pdf_outcome_for_row(row=_row(), request_email="user@example.com") + + assert result.outcome is not None + assert result.outcome.pdf_url == "https://oa.example.org/found.pdf" + assert result.outcome.source == "unpaywall" + + +@pytest.mark.asyncio +async def test_pipeline_uses_unlabeled_fallback_after_oa_failure(monkeypatch: pytest.MonkeyPatch) -> None: + fallback_candidate = _Candidate( + url="https://example.org/download/42", + confidence_score=0.2, + label_present=False, + reason="scholar_link_unlabeled_fallback", + ) + + async def _fake_candidates(_url): + return _Candidates(container_seen=True, labeled_candidate=None, fallback_candidate=fallback_candidate) + + async def _fake_oa(*, row, request_email): + assert request_email == "user@example.com" + return _oa_outcome(pdf_url=None) + + async def _fake_fallback(*, row, candidate): + assert candidate == fallback_candidate + return OaResolutionOutcome( + publication_id=row.publication_id, + doi=row.doi, + pdf_url="https://example.org/fallback.pdf", + failure_reason=None, + source=pipeline.PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE_UNLABELED, + used_crossref=False, + ) + + monkeypatch.setattr(pipeline, "fetch_link_candidates_from_scholar_publication_page", _fake_candidates) + monkeypatch.setattr(pipeline, "_oa_outcome", _fake_oa) + monkeypatch.setattr(pipeline, "_unlabeled_fallback_outcome", _fake_fallback) + + result = await pipeline.resolve_publication_pdf_outcome_for_row(row=_row(), request_email="user@example.com") + + assert result.outcome is not None + assert result.outcome.pdf_url == "https://example.org/fallback.pdf" + assert result.outcome.source == pipeline.PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE_UNLABELED diff --git a/tests/unit/test_scholar_publication_pdf.py b/tests/unit/test_scholar_publication_pdf.py new file mode 100644 index 0000000..7382b77 --- /dev/null +++ b/tests/unit/test_scholar_publication_pdf.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import pytest + +from app.services.domains.scholar.parser_types import ScholarDomInvariantError +from app.services.domains.scholar.publication_pdf import ( + extract_link_candidates_from_publication_detail_html, + is_scholar_publication_detail_url, +) + + +def test_extract_link_candidates_from_publication_detail_html_reads_gsc_oci_pdf_link() -> None: + html = """ + + + + """ + candidates = extract_link_candidates_from_publication_detail_html(html) + assert candidates.labeled_candidate is not None + assert candidates.labeled_candidate.url == "https://arxiv.org/pdf/1703.06103" + + +def test_extract_link_candidates_from_publication_detail_html_returns_no_candidates_when_container_missing() -> None: + html = "
No PDF section
" + candidates = extract_link_candidates_from_publication_detail_html(html) + assert candidates.container_seen is False + assert candidates.labeled_candidate is None + assert candidates.fallback_candidate is None + + +def test_extract_pdf_url_from_publication_detail_html_fails_fast_on_malformed_pdf_container() -> None: + html = """ + + + + """ + with pytest.raises(ScholarDomInvariantError) as exc: + extract_link_candidates_from_publication_detail_html(html) + assert exc.value.code == "layout_publication_link_missing_href" + + +def test_extract_link_candidates_from_publication_detail_html_keeps_unlabeled_fallback() -> None: + html = """ + + + + """ + candidates = extract_link_candidates_from_publication_detail_html(html) + assert candidates.container_seen is True + assert candidates.labeled_candidate is None + assert candidates.fallback_candidate is not None + assert candidates.fallback_candidate.url == "https://example.org/download?id=42" + assert candidates.fallback_candidate.label_present is False + assert "scholar_publication_link_unlabeled_only" in candidates.warnings + assert candidates.labeled_candidate is None + + +def test_is_scholar_publication_detail_url_matches_view_citation_links() -> None: + assert is_scholar_publication_detail_url( + "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=8200InoAAAAJ&citation_for_view=8200InoAAAAJ:gsN89kCJA0AC" + ) is True + assert is_scholar_publication_detail_url("https://example.org/paper") is False diff --git a/tests/unit/test_unpaywall_resolution.py b/tests/unit/test_unpaywall_resolution.py index 5cc34ce..651b33d 100644 --- a/tests/unit/test_unpaywall_resolution.py +++ b/tests/unit/test_unpaywall_resolution.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import replace from datetime import datetime, timezone import pytest @@ -38,6 +39,26 @@ def _item(publication_id: int) -> PublicationListItem: ) +def test_publication_doi_uses_stored_value_when_metadata_has_no_doi() -> None: + item = replace( + _item(99), + pub_url="https://scholar.google.com/citations?view_op=view_citation&citation_for_view=abc:123", + venue_text="Cell 130 (5), 2007", + doi="10.1016/j.cell.2007.11.019", + ) + assert unpaywall_app._publication_doi(item) == "10.1016/j.cell.2007.11.019" + + +def test_publication_doi_prefers_explicit_metadata_doi_over_stored_value() -> None: + item = replace( + _item(100), + pub_url="https://doi.org/10.2000/fresh-value", + venue_text="Cell", + doi="10.1000/stale-value", + ) + assert unpaywall_app._publication_doi(item) == "10.2000/fresh-value" + + @pytest.mark.asyncio async def test_unpaywall_resolve_prefers_direct_pdf_without_landing_crawl(monkeypatch: pytest.MonkeyPatch) -> None: payload = { @@ -49,7 +70,7 @@ async def test_unpaywall_resolve_prefers_direct_pdf_without_landing_crawl(monkey } async def _fake_resolve_item_payload(**_kwargs): - return payload, False + return payload, False, "10.1016/j.cell.2007.11.019" async def _fail_crawl(_client, *, page_url: str): raise AssertionError(f"unexpected landing crawl: {page_url}") @@ -75,7 +96,7 @@ async def test_unpaywall_resolve_crawls_landing_page_when_pdf_url_is_not_direct( crawled_pages: list[str] = [] async def _fake_resolve_item_payload(**_kwargs): - return payload, False + return payload, False, "10.1016/j.cell.2007.11.019" async def _fake_crawl(_client, *, page_url: str): crawled_pages.append(page_url) @@ -87,3 +108,22 @@ async def test_unpaywall_resolve_crawls_landing_page_when_pdf_url_is_not_direct( resolved = await unpaywall_app.resolve_publication_oa_metadata([_item(2)], request_email="user@example.com") assert resolved == {2: ("10.1016/j.cell.2007.11.019", "https://oa.example.org/files/paper-42.pdf")} assert "https://oa.example.org/landing/42" in crawled_pages + + +@pytest.mark.asyncio +async def test_unpaywall_preserves_crossref_doi_when_unpaywall_has_no_record( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_resolve_item_payload(**_kwargs): + return None, True, "10.2000/crossref-only" + + monkeypatch.setattr(unpaywall_app, "_resolve_item_payload", _fake_resolve_item_payload) + monkeypatch.setattr("httpx.AsyncClient", _DummyAsyncClient) + + outcomes = await unpaywall_app.resolve_publication_oa_outcomes([_item(3)], request_email="user@example.com") + + outcome = outcomes[3] + assert outcome.doi == "10.2000/crossref-only" + assert outcome.pdf_url is None + assert outcome.failure_reason == unpaywall_app.FAILURE_NO_RECORD + assert outcome.used_crossref is True From 8760f27b51417e676a040bede3650958cbd1f935 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Sun, 22 Feb 2026 16:35:39 +0100 Subject: [PATCH 04/68] small ui update --- frontend/src/pages/PublicationsPage.vue | 10 ++++++++-- frontend/src/pages/ScholarsPage.vue | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/PublicationsPage.vue b/frontend/src/pages/PublicationsPage.vue index beeedc7..21afc03 100644 --- a/frontend/src/pages/PublicationsPage.vue +++ b/frontend/src/pages/PublicationsPage.vue @@ -802,8 +802,13 @@ watch( diff --git a/frontend/src/pages/ScholarsPage.vue b/frontend/src/pages/ScholarsPage.vue index 4c8fb57..a8794f2 100644 --- a/frontend/src/pages/ScholarsPage.vue +++ b/frontend/src/pages/ScholarsPage.vue @@ -869,7 +869,7 @@ onMounted(() => { />
-
    +
    • Date: Thu, 26 Feb 2026 12:54:19 +0100 Subject: [PATCH 05/68] temp commit --- README.md | 41 +- agents.md | 48 +- ...8_add_openalex_enriched_to_publications.py | 30 + ...0260224_0019_add_resolving_to_runstatus.py | 31 + ...60224_0020_add_openalex_last_attempt_at.py | 26 + ...0021_enforce_single_active_run_per_user.py | 70 + ...dd_canonical_title_hash_to_publications.py | 35 + ...ef777_remove_doi_from_publications_and_.py | 27 + ...add_api_integration_keys_to_usersetting.py | 39 + app/api/routers/admin_dbops.py | 62 +- app/api/routers/publications.py | 53 +- app/api/routers/runs.py | 194 +- app/api/routers/settings.py | 7 + app/api/schemas.py | 12 +- app/db/models.py | 25 +- app/main.py | 12 + app/services/domains/arxiv/application.py | 110 + app/services/domains/ingestion/application.py | 862 +++++- .../domains/ingestion/fingerprints.py | 141 +- app/services/domains/ingestion/scheduler.py | 26 + app/services/domains/ingestion/types.py | 2 + app/services/domains/openalex/__init__.py | 5 + app/services/domains/openalex/client.py | 151 ++ app/services/domains/openalex/matching.py | 108 + app/services/domains/openalex/types.py | 71 + app/services/domains/portability/exporting.py | 3 - .../domains/portability/publication_import.py | 8 - .../publication_identifiers/application.py | 90 +- .../publication_identifiers/normalize.py | 2 +- .../domains/publications/application.py | 4 +- app/services/domains/publications/counts.py | 19 +- app/services/domains/publications/dedup.py | 103 + app/services/domains/publications/listing.py | 16 +- .../domains/publications/pdf_queue.py | 140 +- .../publications/pdf_resolution_pipeline.py | 201 +- app/services/domains/publications/queries.py | 56 +- app/services/domains/publications/types.py | 2 - app/services/domains/runs/events.py | 59 + .../domains/scholar/publication_pdf.py | 232 -- app/services/domains/settings/application.py | 15 +- app/services/domains/unpaywall/application.py | 10 +- app/settings.py | 14 +- build/lib/app/__init__.py | 1 + build/lib/app/api/__init__.py | 2 + build/lib/app/api/deps.py | 35 + build/lib/app/api/errors.py | 85 + build/lib/app/api/media.py | 63 + build/lib/app/api/responses.py | 64 + build/lib/app/api/router.py | 14 + build/lib/app/api/routers/__init__.py | 2 + build/lib/app/api/routers/admin.py | 202 ++ build/lib/app/api/routers/admin_dbops.py | 349 +++ build/lib/app/api/routers/auth.py | 237 ++ build/lib/app/api/routers/publications.py | 528 ++++ build/lib/app/api/routers/runs.py | 845 ++++++ build/lib/app/api/routers/scholars.py | 557 ++++ build/lib/app/api/routers/settings.py | 189 ++ build/lib/app/api/runtime_deps.py | 16 + build/lib/app/api/schemas.py | 884 ++++++ build/lib/app/auth/__init__.py | 2 + build/lib/app/auth/deps.py | 27 + build/lib/app/auth/rate_limit.py | 69 + build/lib/app/auth/runtime.py | 55 + build/lib/app/auth/security.py | 19 + build/lib/app/auth/service.py | 38 + build/lib/app/auth/session.py | 50 + build/lib/app/db/__init__.py | 1 + build/lib/app/db/base.py | 27 + build/lib/app/db/models.py | 546 ++++ build/lib/app/db/session.py | 99 + build/lib/app/http/__init__.py | 1 + build/lib/app/http/middleware.py | 205 ++ build/lib/app/logging_config.py | 183 ++ build/lib/app/logging_context.py | 15 + build/lib/app/main.py | 168 ++ build/lib/app/security/__init__.py | 2 + build/lib/app/security/csrf.py | 133 + build/lib/app/services/__init__.py | 2 + build/lib/app/services/domains/__init__.py | 1 + .../app/services/domains/arxiv/application.py | 84 + .../app/services/domains/crossref/__init__.py | 5 + .../services/domains/crossref/application.py | 382 +++ .../app/services/domains/dbops/__init__.py | 5 + .../app/services/domains/dbops/application.py | 364 +++ .../app/services/domains/dbops/integrity.py | 175 ++ build/lib/app/services/domains/dbops/query.py | 22 + .../lib/app/services/domains/doi/normalize.py | 23 + .../services/domains/ingestion/__init__.py | 1 + .../services/domains/ingestion/application.py | 2413 +++++++++++++++++ .../services/domains/ingestion/constants.py | 31 + .../domains/ingestion/fingerprints.py | 175 ++ .../app/services/domains/ingestion/queue.py | 348 +++ .../app/services/domains/ingestion/safety.py | 282 ++ .../services/domains/ingestion/scheduler.py | 622 +++++ .../app/services/domains/ingestion/types.py | 110 + .../app/services/domains/openalex/__init__.py | 5 + .../app/services/domains/openalex/client.py | 135 + .../app/services/domains/openalex/matching.py | 108 + .../app/services/domains/openalex/types.py | 71 + .../services/domains/portability/__init__.py | 1 + .../domains/portability/application.py | 67 + .../services/domains/portability/constants.py | 9 + .../services/domains/portability/exporting.py | 91 + .../services/domains/portability/normalize.py | 109 + .../domains/portability/publication_import.py | 370 +++ .../domains/portability/scholar_import.py | 107 + .../app/services/domains/portability/types.py | 25 + .../publication_identifiers/__init__.py | 17 + .../publication_identifiers/application.py | 433 +++ .../publication_identifiers/normalize.py | 76 + .../domains/publication_identifiers/types.py | 30 + .../services/domains/publications/__init__.py | 1 + .../domains/publications/application.py | 74 + .../services/domains/publications/counts.py | 90 + .../domains/publications/enrichment.py | 73 + .../services/domains/publications/listing.py | 93 + .../services/domains/publications/modes.py | 14 + .../domains/publications/pdf_queue.py | 915 +++++++ .../publications/pdf_resolution_pipeline.py | 108 + .../services/domains/publications/queries.py | 203 ++ .../domains/publications/read_state.py | 94 + .../services/domains/publications/types.py | 41 + .../lib/app/services/domains/runs/__init__.py | 1 + .../app/services/domains/runs/application.py | 45 + build/lib/app/services/domains/runs/events.py | 59 + .../services/domains/runs/queue_queries.py | 70 + .../services/domains/runs/queue_service.py | 182 ++ .../app/services/domains/runs/runs_service.py | 79 + .../lib/app/services/domains/runs/summary.py | 76 + build/lib/app/services/domains/runs/types.py | 38 + .../app/services/domains/scholar/__init__.py | 1 + .../services/domains/scholar/author_rows.py | 202 ++ .../app/services/domains/scholar/parser.py | 165 ++ .../domains/scholar/parser_constants.py | 87 + .../services/domains/scholar/parser_types.py | 75 + .../services/domains/scholar/parser_utils.py | 42 + .../services/domains/scholar/profile_rows.py | 270 ++ .../services/domains/scholar/rate_limit.py | 18 + .../app/services/domains/scholar/source.py | 235 ++ .../domains/scholar/state_detection.py | 164 ++ .../app/services/domains/scholars/__init__.py | 1 + .../services/domains/scholars/application.py | 974 +++++++ .../services/domains/scholars/constants.py | 70 + .../services/domains/scholars/exceptions.py | 5 + .../services/domains/scholars/search_hints.py | 67 + .../app/services/domains/scholars/uploads.py | 39 + .../services/domains/scholars/validators.py | 38 + .../app/services/domains/settings/__init__.py | 1 + .../services/domains/settings/application.py | 159 ++ .../services/domains/unpaywall/__init__.py | 10 + .../services/domains/unpaywall/application.py | 458 ++++ .../domains/unpaywall/pdf_discovery.py | 156 ++ .../services/domains/unpaywall/rate_limit.py | 18 + .../app/services/domains/users/__init__.py | 1 + .../app/services/domains/users/application.py | 98 + build/lib/app/settings.py | 267 ++ diag_pubs.py | 39 + docs/developer/api-contract.md | 12 + docs/developer/architecture.md | 32 +- docs/developer/ingestion.md | 26 + docs/developer/overview.md | 2 + frontend/src/components/layout/AppNav.vue | 4 + .../components/patterns/RunStatusBadge.vue | 6 + frontend/src/features/admin_dbops/index.ts | 17 +- frontend/src/features/publications/index.ts | 18 +- frontend/src/features/runs/index.ts | 7 + frontend/src/features/scholars/index.ts | 3 +- .../features/settings/SettingsAdminPanel.vue | 64 + frontend/src/features/settings/index.ts | 6 + frontend/src/pages/DashboardPage.vue | 63 +- frontend/src/pages/PublicationsPage.vue | 154 +- frontend/src/pages/RunsPage.vue | 43 +- frontend/src/pages/SettingsPage.vue | 56 + frontend/src/stores/run_status.test.ts | 88 +- frontend/src/stores/run_status.ts | 157 +- frontend/src/stores/user_settings.test.ts | 3 + pyproject.toml | 2 + test_enrich.py | 42 + tests/integration/test_api_v1.py | 238 +- tests/integration/test_deferred_enrichment.py | 103 + tests/integration/test_fixture_probe_runs.py | 4 +- tests/integration/test_migrations.py | 24 +- .../test_run_lifecycle_consistency.py | 514 ++++ .../services/domains/openalex/test_client.py | 64 + .../domains/openalex/test_matching.py | 55 + .../domains/publications/test_dedup.py | 163 ++ tests/unit/test_fingerprints.py | 242 ++ tests/unit/test_publication_identifiers.py | 69 + .../unit/test_publication_pdf_queue_policy.py | 72 +- ...est_publication_pdf_resolution_pipeline.py | 149 +- tests/unit/test_scholar_publication_pdf.py | 76 - tests/unit/test_unpaywall_resolution.py | 19 +- uv.lock | 76 + 193 files changed, 23228 insertions(+), 935 deletions(-) create mode 100644 alembic/versions/20260224_0018_add_openalex_enriched_to_publications.py create mode 100644 alembic/versions/20260224_0019_add_resolving_to_runstatus.py create mode 100644 alembic/versions/20260224_0020_add_openalex_last_attempt_at.py create mode 100644 alembic/versions/20260225_0021_enforce_single_active_run_per_user.py create mode 100644 alembic/versions/20260225_0022_add_canonical_title_hash_to_publications.py create mode 100644 alembic/versions/44f7e10ef777_remove_doi_from_publications_and_.py create mode 100644 alembic/versions/fa0c5e3262d5_add_api_integration_keys_to_usersetting.py create mode 100644 app/services/domains/arxiv/application.py create mode 100644 app/services/domains/openalex/__init__.py create mode 100644 app/services/domains/openalex/client.py create mode 100644 app/services/domains/openalex/matching.py create mode 100644 app/services/domains/openalex/types.py create mode 100644 app/services/domains/publications/dedup.py create mode 100644 app/services/domains/runs/events.py delete mode 100644 app/services/domains/scholar/publication_pdf.py create mode 100644 build/lib/app/__init__.py create mode 100644 build/lib/app/api/__init__.py create mode 100644 build/lib/app/api/deps.py create mode 100644 build/lib/app/api/errors.py create mode 100644 build/lib/app/api/media.py create mode 100644 build/lib/app/api/responses.py create mode 100644 build/lib/app/api/router.py create mode 100644 build/lib/app/api/routers/__init__.py create mode 100644 build/lib/app/api/routers/admin.py create mode 100644 build/lib/app/api/routers/admin_dbops.py create mode 100644 build/lib/app/api/routers/auth.py create mode 100644 build/lib/app/api/routers/publications.py create mode 100644 build/lib/app/api/routers/runs.py create mode 100644 build/lib/app/api/routers/scholars.py create mode 100644 build/lib/app/api/routers/settings.py create mode 100644 build/lib/app/api/runtime_deps.py create mode 100644 build/lib/app/api/schemas.py create mode 100644 build/lib/app/auth/__init__.py create mode 100644 build/lib/app/auth/deps.py create mode 100644 build/lib/app/auth/rate_limit.py create mode 100644 build/lib/app/auth/runtime.py create mode 100644 build/lib/app/auth/security.py create mode 100644 build/lib/app/auth/service.py create mode 100644 build/lib/app/auth/session.py create mode 100644 build/lib/app/db/__init__.py create mode 100644 build/lib/app/db/base.py create mode 100644 build/lib/app/db/models.py create mode 100644 build/lib/app/db/session.py create mode 100644 build/lib/app/http/__init__.py create mode 100644 build/lib/app/http/middleware.py create mode 100644 build/lib/app/logging_config.py create mode 100644 build/lib/app/logging_context.py create mode 100644 build/lib/app/main.py create mode 100644 build/lib/app/security/__init__.py create mode 100644 build/lib/app/security/csrf.py create mode 100644 build/lib/app/services/__init__.py create mode 100644 build/lib/app/services/domains/__init__.py create mode 100644 build/lib/app/services/domains/arxiv/application.py create mode 100644 build/lib/app/services/domains/crossref/__init__.py create mode 100644 build/lib/app/services/domains/crossref/application.py create mode 100644 build/lib/app/services/domains/dbops/__init__.py create mode 100644 build/lib/app/services/domains/dbops/application.py create mode 100644 build/lib/app/services/domains/dbops/integrity.py create mode 100644 build/lib/app/services/domains/dbops/query.py create mode 100644 build/lib/app/services/domains/doi/normalize.py create mode 100644 build/lib/app/services/domains/ingestion/__init__.py create mode 100644 build/lib/app/services/domains/ingestion/application.py create mode 100644 build/lib/app/services/domains/ingestion/constants.py create mode 100644 build/lib/app/services/domains/ingestion/fingerprints.py create mode 100644 build/lib/app/services/domains/ingestion/queue.py create mode 100644 build/lib/app/services/domains/ingestion/safety.py create mode 100644 build/lib/app/services/domains/ingestion/scheduler.py create mode 100644 build/lib/app/services/domains/ingestion/types.py create mode 100644 build/lib/app/services/domains/openalex/__init__.py create mode 100644 build/lib/app/services/domains/openalex/client.py create mode 100644 build/lib/app/services/domains/openalex/matching.py create mode 100644 build/lib/app/services/domains/openalex/types.py create mode 100644 build/lib/app/services/domains/portability/__init__.py create mode 100644 build/lib/app/services/domains/portability/application.py create mode 100644 build/lib/app/services/domains/portability/constants.py create mode 100644 build/lib/app/services/domains/portability/exporting.py create mode 100644 build/lib/app/services/domains/portability/normalize.py create mode 100644 build/lib/app/services/domains/portability/publication_import.py create mode 100644 build/lib/app/services/domains/portability/scholar_import.py create mode 100644 build/lib/app/services/domains/portability/types.py create mode 100644 build/lib/app/services/domains/publication_identifiers/__init__.py create mode 100644 build/lib/app/services/domains/publication_identifiers/application.py create mode 100644 build/lib/app/services/domains/publication_identifiers/normalize.py create mode 100644 build/lib/app/services/domains/publication_identifiers/types.py create mode 100644 build/lib/app/services/domains/publications/__init__.py create mode 100644 build/lib/app/services/domains/publications/application.py create mode 100644 build/lib/app/services/domains/publications/counts.py create mode 100644 build/lib/app/services/domains/publications/enrichment.py create mode 100644 build/lib/app/services/domains/publications/listing.py create mode 100644 build/lib/app/services/domains/publications/modes.py create mode 100644 build/lib/app/services/domains/publications/pdf_queue.py create mode 100644 build/lib/app/services/domains/publications/pdf_resolution_pipeline.py create mode 100644 build/lib/app/services/domains/publications/queries.py create mode 100644 build/lib/app/services/domains/publications/read_state.py create mode 100644 build/lib/app/services/domains/publications/types.py create mode 100644 build/lib/app/services/domains/runs/__init__.py create mode 100644 build/lib/app/services/domains/runs/application.py create mode 100644 build/lib/app/services/domains/runs/events.py create mode 100644 build/lib/app/services/domains/runs/queue_queries.py create mode 100644 build/lib/app/services/domains/runs/queue_service.py create mode 100644 build/lib/app/services/domains/runs/runs_service.py create mode 100644 build/lib/app/services/domains/runs/summary.py create mode 100644 build/lib/app/services/domains/runs/types.py create mode 100644 build/lib/app/services/domains/scholar/__init__.py create mode 100644 build/lib/app/services/domains/scholar/author_rows.py create mode 100644 build/lib/app/services/domains/scholar/parser.py create mode 100644 build/lib/app/services/domains/scholar/parser_constants.py create mode 100644 build/lib/app/services/domains/scholar/parser_types.py create mode 100644 build/lib/app/services/domains/scholar/parser_utils.py create mode 100644 build/lib/app/services/domains/scholar/profile_rows.py create mode 100644 build/lib/app/services/domains/scholar/rate_limit.py create mode 100644 build/lib/app/services/domains/scholar/source.py create mode 100644 build/lib/app/services/domains/scholar/state_detection.py create mode 100644 build/lib/app/services/domains/scholars/__init__.py create mode 100644 build/lib/app/services/domains/scholars/application.py create mode 100644 build/lib/app/services/domains/scholars/constants.py create mode 100644 build/lib/app/services/domains/scholars/exceptions.py create mode 100644 build/lib/app/services/domains/scholars/search_hints.py create mode 100644 build/lib/app/services/domains/scholars/uploads.py create mode 100644 build/lib/app/services/domains/scholars/validators.py create mode 100644 build/lib/app/services/domains/settings/__init__.py create mode 100644 build/lib/app/services/domains/settings/application.py create mode 100644 build/lib/app/services/domains/unpaywall/__init__.py create mode 100644 build/lib/app/services/domains/unpaywall/application.py create mode 100644 build/lib/app/services/domains/unpaywall/pdf_discovery.py create mode 100644 build/lib/app/services/domains/unpaywall/rate_limit.py create mode 100644 build/lib/app/services/domains/users/__init__.py create mode 100644 build/lib/app/services/domains/users/application.py create mode 100644 build/lib/app/settings.py create mode 100644 diag_pubs.py create mode 100644 docs/developer/api-contract.md create mode 100644 docs/developer/ingestion.md create mode 100644 test_enrich.py create mode 100644 tests/integration/test_deferred_enrichment.py create mode 100644 tests/integration/test_run_lifecycle_consistency.py create mode 100644 tests/unit/services/domains/openalex/test_client.py create mode 100644 tests/unit/services/domains/openalex/test_matching.py create mode 100644 tests/unit/services/domains/publications/test_dedup.py create mode 100644 tests/unit/test_fingerprints.py delete mode 100644 tests/unit/test_scholar_publication_pdf.py diff --git a/README.md b/README.md index 5d5f84f..c05beaa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
      -Self-hosted scholar tracking with a single app image (API + frontend). +Self-hosted scholar tracking with a single app image (API + frontend). It automates publication discovery, parses Google Scholar, gathers external metadata via Crossref & arXiv APIs, and autonomously downloads Open Access PDFs. [![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) [![Docker Pulls](https://img.shields.io/docker/pulls/justinzeus/scholarr?style=for-the-badge&logo=docker)](https://hub.docker.com/r/justinzeus/scholarr) @@ -10,6 +10,25 @@ Self-hosted scholar tracking with a single app image (API + frontend).
      +## Key Features +- **Automated Ingestion**: Background tasks regularly scrape author profiles to pull the latest publications. +- **Resilient Parsing**: Custom DOM parsing logic falls back heavily across changes, backed by a dynamic `429 Too Many Requests` backoff cycle. +- **Identifier Subsystem**: Isolates DOIs, PMIDs, and arXiv IDs independently of PDFs mapping fallback external APIs sequentially. +- **Unpaywall PDF Search**: Leverages a fully automated resolution queue to grab public PDFs via the Unpaywall API on identified entries. +- **Beautiful UI**: Polished Vue 3 frontend that ships natively inside the same Docker container as the FastAPI backend. + +## Architecture Data Flow +```mermaid +graph LR + HTML(Google Scholar) --> |Scraper| Ingestion + Ingestion --> |Extract DOI| DB[(Database)] + Ingestion -.-> |Fallback API Search| Crossref/arXiv + Crossref/arXiv --> DB + + DB --> |Identified DOIs| PDFQueue(Unpaywall/PDF Workers) + PDFQueue --> |Download & Store File| Storage +``` + ## Quick Start 1. Copy env template: @@ -36,10 +55,12 @@ Open: ## Documentation Complete documentation is published at: - - https://justinzeus.github.io/scholarr/ -Source markdown and docs tooling live under `docs/`. +You can also read the developer/architecture breakdowns directly within the repository under: +- `docs/developer/architecture.md` +- `docs/developer/ingestion.md` +- `docs/developer/api-contract.md` ## Quality Gates @@ -53,11 +74,14 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app uv r Frontend: ```bash -cd frontend -npm install -npm run typecheck -npm run test:run -npm run build +# ALL tests/builds MUST be run within the docker container. Do not run raw npm commands on the host. +# For automated agents checking outputs, redirect stdout/stderr to files (e.g. > /tmp/npm_typecheck.log 2>&1) +# The temp files /tmp/npm_test.log, /tmp/npm_lint.log, /tmp/npm_typecheck.log, and /tmp/pytest_eval.log are pre-authorized for overwrite. + +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm frontend npm install +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm frontend npm run typecheck +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm frontend npm run test:run +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm frontend npm run build ``` Contract and env checks: @@ -67,3 +91,4 @@ python3 scripts/check_frontend_api_contract.py python3 scripts/check_env_contract.py ./scripts/check_no_generated_artifacts.sh ``` +thanks arxiv \ No newline at end of file diff --git a/agents.md b/agents.md index da59b76..a114a45 100644 --- a/agents.md +++ b/agents.md @@ -3,35 +3,45 @@ Adhere strictly to these constraints. ## 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. -no magic numbers + +- **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. + no magic numbers ## 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": "..."}}` + +- **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": "..."}}` ## 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. + +- **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. ## 4. Current Environment & Stack -* **Backend:** Python 3.12+, FastAPI, SQLAlchemy (Async/asyncpg), Alembic. -* **Frontend:** TypeScript, Vue 3, Vite. -* **Infrastructure:** Multi-stage Docker. + +- **Backend:** Python 3.12+, FastAPI, SQLAlchemy (Async/asyncpg), Alembic. +- **Frontend:** TypeScript, Vue 3, Vite. +- **Infrastructure:** Multi-stage Docker. ## 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/`. +- **Strict Modularity:** Flat files in the `app/services/` root are strictly prohibited. All business logic and routing must reside exclusively within `app/services/domains/`. ## 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 +Clarity through both styling and language are a priority. all UI elements need to have a proper reason for existing. + +## 7. Testing + +All tests need to be ran using containers. `docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app` 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..1d7ecad --- /dev/null +++ b/alembic/versions/20260224_0018_add_openalex_enriched_to_publications.py @@ -0,0 +1,30 @@ +"""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 + +from alembic import op +import sqlalchemy as sa + + +# 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..645a32b --- /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..024f547 --- /dev/null +++ b/alembic/versions/20260224_0020_add_openalex_last_attempt_at.py @@ -0,0 +1,26 @@ +"""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 + +from alembic import op +import sqlalchemy as sa + + +# 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..daaa2a6 --- /dev/null +++ b/alembic/versions/20260225_0021_enforce_single_active_run_per_user.py @@ -0,0 +1,70 @@ +"""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 + +from alembic import op +import sqlalchemy as sa + + +# 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..f73d280 --- /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 + +from alembic import op +import sqlalchemy as sa + + +# 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/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/routers/admin_dbops.py b/app/api/routers/admin_dbops.py index cd6d924..b3c1fdf 100644 --- a/app/api/routers/admin_dbops.py +++ b/app/api/routers/admin_dbops.py @@ -57,7 +57,6 @@ 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, @@ -348,3 +347,64 @@ async def trigger_publication_link_repair( }, ) 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() + + logger.warning( + "api.admin.db.all_publications_dropped", + extra={ + "event": "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/publications.py b/app/api/routers/publications.py index ad8fc55..b4a616e 100644 --- a/app/api/routers/publications.py +++ b/app/api/routers/publications.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime, timezone import logging from typing import Literal @@ -62,7 +63,6 @@ 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, @@ -94,23 +94,27 @@ async def _publication_counts( user_id: int, selected_scholar_id: int | None, favorite_only: bool, + 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, @@ -118,6 +122,7 @@ async def _publication_counts( mode=publication_service.MODE_ALL, scholar_profile_id=selected_scholar_id, favorite_only=favorite_only, + snapshot_before=snapshot_before, ) return unread_count, favorites_count, latest_count, total_count @@ -129,8 +134,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 @@ -145,8 +154,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, @@ -162,6 +175,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(timezone.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=timezone.utc) + normalized = parsed.astimezone(timezone.utc) + return normalized, normalized.isoformat() + + def _resolve_publications_paging( *, page: int, @@ -188,6 +222,7 @@ def _publications_list_data( page: int, page_size: int, offset: int, + snapshot: str, ) -> dict[str, object]: return { "mode": mode, @@ -200,6 +235,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], @@ -322,7 +358,6 @@ def _log_retry_pdf_result( "queued": queued, "resolved_pdf": resolved_pdf, "pdf_status": pdf_status, - "has_doi": bool(doi), }, ) @@ -336,10 +371,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"] = 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), ): @@ -349,20 +388,27 @@ 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, + snapshot_before=snapshot_before, ) data = _publications_list_data( mode=resolved_mode, @@ -376,6 +422,7 @@ async def list_publications( page=resolved_page, page_size=resolved_limit, offset=resolved_offset, + snapshot=snapshot_cursor, ) return success_payload(request, data=data) @@ -480,7 +527,7 @@ async def retry_publication_pdf( queued=queued, resolved_pdf=resolved_pdf, pdf_status=current.pdf_status, - doi=current.doi, + doi=None, ) return success_payload( request, diff --git a/app/api/routers/runs.py b/app/api/routers/runs.py index ddd5290..b03a45d 100644 --- a/app/api/routers/runs.py +++ b/app/api/routers/runs.py @@ -11,6 +11,7 @@ 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 fastapi.responses import StreamingResponse from app.api.schemas import ( ManualRunEnvelope, QueueClearEnvelope, @@ -24,6 +25,7 @@ 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.runs.events import event_generator from app.services.domains.settings import application as user_settings_service from app.settings import settings from app.api.runtime_deps import get_ingestion_service @@ -31,6 +33,7 @@ from app.api.runtime_deps import get_ingestion_service logger = logging.getLogger(__name__) router = APIRouter(prefix="/runs", tags=["api-runs"]) +ACTIVE_RUN_STATUSES = {RunStatus.RUNNING, RunStatus.RESOLVING} IDEMPOTENCY_HEADER = "Idempotency-Key" IDEMPOTENCY_MAX_LENGTH = 128 @@ -338,7 +341,7 @@ async def _reused_manual_run_payload( ) if previous_run is None: return None - if previous_run.status == RunStatus.RUNNING: + if previous_run.status in (RunStatus.RUNNING, RunStatus.RESOLVING): raise ApiException( status_code=409, code="run_in_progress", @@ -410,7 +413,7 @@ async def _recover_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: + if existing_run.status in (RunStatus.RUNNING, RunStatus.RESOLVING): raise ApiException( status_code=409, code="run_in_progress", @@ -586,6 +589,61 @@ async def get_run( ) +@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, + }, + ) + + @router.post( "/manual", response_model=ManualRunEnvelope, @@ -596,10 +654,7 @@ 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, - ) + safety_state = await _load_safety_state(db_session, user_id=current_user.id) if not settings.ingestion_manual_run_allowed: _raise_manual_runs_disabled(user_id=current_user.id, safety_state=safety_state) @@ -614,28 +669,90 @@ async def run_manual( 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 = await user_settings_service.get_or_create_settings(db_session, user_id=current_user.id) + + # Initialize run (creates the record and performs safety checks) + 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, - safety_state=current_safety_state, - ), - ) + 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, + ) + + await db_session.commit() + + # Kick off background execution + from app.db.session import get_session_factory + import asyncio + + 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, + ) + ) + + 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=current_user.id), + } + ) + except ingestion_service.RunBlockedBySafetyPolicyError as exc: + await db_session.rollback() + _raise_manual_blocked_safety(exc=exc, user_id=current_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=current_user.id, + idempotency_key=idempotency_key, + original_exc=exc, + ) + except Exception as exc: + await db_session.rollback() + _raise_manual_failed(exc=exc, user_id=current_user.id) + @router.get( @@ -769,3 +886,26 @@ async def clear_queue_item( "message": "Queue item cleared.", }, ) + + +@router.get("/{run_id}/stream") +async def stream_run_events( + run_id: int, + 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.", + ) + return StreamingResponse( + event_generator(run_id), + media_type="text/event-stream" + ) diff --git a/app/api/routers/settings.py b/app/api/routers/settings.py index f5e9cac..5a2ef91 100644 --- a/app/api/routers/settings.py +++ b/app/api/routers/settings.py @@ -38,6 +38,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, } @@ -107,6 +110,7 @@ def _log_settings_update(*, user_id: int, updated) -> None: "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", }, ) @@ -169,6 +173,9 @@ 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, diff --git a/app/api/schemas.py b/app/api/schemas.py index 112f672..07ccedf 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -205,7 +205,6 @@ class PublicationExportItemData(BaseModel): 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 @@ -594,7 +593,6 @@ class DisplayIdentifierData(BaseModel): class AdminPdfQueueItemData(BaseModel): publication_id: int title: str - doi: str | None display_identifier: DisplayIdentifierData | None = None pdf_url: str | None status: str @@ -725,6 +723,10 @@ class SettingsData(BaseModel): 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") @@ -742,6 +744,10 @@ class SettingsUpdateRequest(BaseModel): 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") @@ -754,7 +760,6 @@ class PublicationItemData(BaseModel): citation_count: int venue_text: str | None pub_url: str | None - doi: str | None display_identifier: DisplayIdentifierData | None = None pdf_url: str | None pdf_status: str = "untracked" @@ -780,6 +785,7 @@ class PublicationsListData(BaseModel): total_count: int page: int page_size: int + snapshot: str has_next: bool = False has_prev: bool = False publications: list[PublicationItemData] diff --git a/app/db/models.py b/app/db/models.py index 1f0b250..c687547 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -31,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): @@ -113,6 +115,11 @@ class UserSetting(Base): ) scrape_cooldown_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) scrape_cooldown_reason: Mapped[str | None] = mapped_column(String(64)) + + 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() ) @@ -161,6 +168,14 @@ 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", @@ -228,8 +243,16 @@ class Publication(Base): 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) + 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() ) diff --git a/app/main.py b/app/main.py index 254eb4a..56ddf58 100644 --- a/app/main.py +++ b/app/main.py @@ -66,6 +66,18 @@ def _log_startup_build_marker() -> None: @asynccontextmanager async def lifespan(_: FastAPI): _log_startup_build_marker() + + from app.db.session import get_session_factory + from sqlalchemy import text + 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() + logger.info("app.startup_orphaned_runs_cleaned", extra={"event": "app.startup_orphaned_runs_cleaned"}) + except Exception as e: + logger.error(f"Failed to clean orphaned runs at startup: {e}") + await scheduler_service.start() yield await scheduler_service.stop() diff --git a/app/services/domains/arxiv/application.py b/app/services/domains/arxiv/application.py new file mode 100644 index 0000000..57b1473 --- /dev/null +++ b/app/services/domains/arxiv/application.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING +import xml.etree.ElementTree as ET + +import httpx + +from app.services.domains.publication_identifiers.normalize import normalize_arxiv_id +from app.settings import settings + +if TYPE_CHECKING: + from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem + +logger = logging.getLogger(__name__) + +# arXiv API terms: max 1 request per 3 seconds, single connection at a time. +_ARXIV_LOCK = asyncio.Lock() +_ARXIV_MIN_INTERVAL_SECONDS = 4.0 +# Global cooldown: when arXiv returns 429, all batches back off for this long. +_ARXIV_RATE_LIMIT_COOLDOWN_SECONDS = 60.0 +_arxiv_rate_limited_until: float = 0.0 # asyncio monotonic time + + +class ArxivRateLimitError(Exception): + """arXiv returned 429 — stop the batch to avoid hammering.""" + pass + + +def _build_arxiv_query(title: str, author_surname: str | None) -> str | None: + parts = [] + if title: + clean_title = title.replace('"', '').replace("'", "") + parts.append(f'ti:"{clean_title}"') + if author_surname: + parts.append(f'au:"{author_surname}"') + if not parts: + return None + return " AND ".join(parts) + + +async def discover_arxiv_id_for_publication( + *, + item: PublicationListItem | UnreadPublicationItem, + request_email: str | None = None, + timeout_seconds: float = 3.0, +) -> str | None: + title = (item.title or "").strip() + if not title: + return None + + author_surname = None + if item.scholar_label: + tokens = [t for t in item.scholar_label.strip().split() if t] + if tokens: + author_surname = tokens[-1].lower() + + query = _build_arxiv_query(title, author_surname) + if not query: + return None + + url = "https://export.arxiv.org/api/query" + params = {"search_query": query, "start": 0, "max_results": 3} + headers = { + "User-Agent": ( + f"scholar-scraper/1.0 " + f"(mailto:{request_email or settings.crossref_api_mailto or 'unknown@example.com'})" + ) + } + + try: + async with _ARXIV_LOCK: + global _arxiv_rate_limited_until + now = asyncio.get_running_loop().time() + if now < _arxiv_rate_limited_until: + remaining = _arxiv_rate_limited_until - now + raise ArxivRateLimitError(f"arXiv global cooldown active ({remaining:.0f}s remaining)") + + async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True, headers=headers) as client: + response = await client.get(url, params=params) + + if response.status_code == 429: + _arxiv_rate_limited_until = asyncio.get_running_loop().time() + _ARXIV_RATE_LIMIT_COOLDOWN_SECONDS + raise ArxivRateLimitError("arXiv rate limit hit (429) — stopping batch") + + await asyncio.sleep(_ARXIV_MIN_INTERVAL_SECONDS) + + response.raise_for_status() + + root = ET.fromstring(response.text) + namespace = {"atom": "http://www.w3.org/2005/Atom"} + + for entry in root.findall("atom:entry", namespace): + id_elem = entry.find("atom:id", namespace) + if id_elem is not None and id_elem.text: + candidate = str(id_elem.text) + if "/abs/" in candidate: + candidate = candidate.split("/abs/")[-1] + normalized = normalize_arxiv_id(candidate) + if normalized: + logger.debug("arxiv.id_discovered", extra={"event": "arxiv.id_discovered", "arxiv_id": normalized}) + return normalized + + except ArxivRateLimitError: + raise # propagate so the batch loop can stop + except Exception as exc: + logger.debug(f"Failed to query arXiv API: {exc}") + + return None diff --git a/app/services/domains/ingestion/application.py b/app/services/domains/ingestion/application.py index 4cb493d..a313a6f 100644 --- a/app/services/domains/ingestion/application.py +++ b/app/services/domains/ingestion/application.py @@ -1,12 +1,14 @@ from __future__ import annotations import asyncio -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import hashlib import logging +import re from typing import Any -from sqlalchemy import select, text +from sqlalchemy import or_, select, text +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import ( @@ -37,6 +39,7 @@ from app.services.domains.ingestion.fingerprints import ( build_initial_page_fingerprint, build_publication_fingerprint, build_publication_url, + canonical_title_for_dedup, normalize_title, ) from app.services.domains.ingestion import queue as queue_service @@ -53,6 +56,7 @@ from app.services.domains.ingestion.types import ( ScholarProcessingOutcome, ) from app.services.domains.settings import application as user_settings_service +from app.services.domains.runs.events import run_events from app.services.domains.scholar.parser import ( ParseState, ParsedProfilePage, @@ -64,6 +68,21 @@ from app.services.domains.scholar.source import FetchResult, ScholarSource 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 _int_or_default(value: Any, default: int = 0) -> int: @@ -357,8 +376,6 @@ class ScholarIngestionService: 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 @@ -522,12 +539,9 @@ class ScholarIngestionService: 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, - ) + # We no longer aggregate and upsert the publications here. + # Eager DB insertions have already saved and committed them inside `_fetch_and_parse_all_pages_with_retry` and `_paginate_loop`. + 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 @@ -535,6 +549,8 @@ class ScholarIngestionService: ) 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"] = self._build_failure_debug_context( @@ -661,6 +677,8 @@ class ScholarIngestionService: 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, @@ -676,6 +694,8 @@ class ScholarIngestionService: 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, start_cstart=start_cstart, @@ -700,6 +720,8 @@ class ScholarIngestionService: 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, @@ -707,6 +729,7 @@ class ScholarIngestionService: queue_delay_seconds: int, ) -> ScholarProcessingOutcome: run_dt, paged_parse_result, result_entry = await self._fetch_and_prepare_scholar_result( + db_session, run=run, scholar=scholar, user_id=user_id, @@ -714,6 +737,8 @@ class ScholarIngestionService: 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, ) @@ -740,6 +765,7 @@ class ScholarIngestionService: async def _fetch_and_prepare_scholar_result( self, + db_session: AsyncSession, *, run: CrawlRun, scholar: ScholarProfile, @@ -748,16 +774,22 @@ class ScholarIngestionService: 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(timezone.utc) paged_parse_result = await self._fetch_and_parse_all_pages_with_retry( - scholar_id=scholar.scholar_id, + 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, @@ -808,7 +840,6 @@ class ScholarIngestionService: 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( @@ -1049,12 +1080,13 @@ class ScholarIngestionService: 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, - ) + if run.status != RunStatus.CANCELED: + 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": { @@ -1114,6 +1146,8 @@ class ScholarIngestionService: 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, ) -> dict[str, Any]: @@ -1121,6 +1155,8 @@ class ScholarIngestionService: "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, } @@ -1155,7 +1191,7 @@ class ScholarIngestionService: 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]]: + 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, rate_limit_retries: int, rate_limit_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, @@ -1230,7 +1266,15 @@ class ScholarIngestionService: idempotency_key=idempotency_key, ) db_session.add(run) - await db_session.flush() + 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 _run_scholar_iteration( @@ -1244,13 +1288,27 @@ class ScholarIngestionService: 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() + + # ── Pass 1: first page of every scholar (breadth-first) ────────── + # This ensures all scholars have visible publications as quickly as + # possible, rather than fully paginating one scholar before the next. + first_pass_cstarts: dict[int, int] = {} for index, scholar in enumerate(scholars): + await db_session.refresh(run) + if run.status == RunStatus.CANCELED: + logger.info( + "ingestion.run_canceled", + extra={"event": "ingestion.run_canceled", "run_id": run.id, "user_id": user_id}, + ) + return progress 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( @@ -1261,9 +1319,50 @@ class ScholarIngestionService: 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, + rate_limit_retries=rate_limit_retries, + rate_limit_backoff_seconds=rate_limit_backoff_seconds, + max_pages_per_scholar=1, page_size=page_size, start_cstart=start_cstart, + auto_queue_continuations=False, + queue_delay_seconds=queue_delay_seconds, + ) + self._apply_outcome_to_progress(progress=progress, run=run, outcome=outcome) + # Track where to resume from for the deep pass + 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) + + # ── Pass 2: remaining pages for each scholar (depth) ───────────── + remaining_max = max(max_pages_per_scholar - 1, 0) + if remaining_max <= 0: + return progress + + for index, scholar in enumerate(scholars): + resume_cstart = first_pass_cstarts.get(int(scholar.id)) + if resume_cstart is None: + continue # first page failed, had no continuation, or was skipped + await db_session.refresh(run) + if run.status == RunStatus.CANCELED: + logger.info( + "ingestion.run_canceled", + extra={"event": "ingestion.run_canceled", "run_id": run.id, "user_id": user_id}, + ) + break + await self._wait_between_scholars(index=index, request_delay_seconds=request_delay_seconds) + 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, + rate_limit_retries=rate_limit_retries, + rate_limit_backoff_seconds=rate_limit_backoff_seconds, + max_pages_per_scholar=remaining_max, + page_size=page_size, + start_cstart=resume_cstart, auto_queue_continuations=auto_queue_continuations, queue_delay_seconds=queue_delay_seconds, ) @@ -1307,17 +1406,50 @@ class ScholarIngestionService: ) 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): + 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): 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, + effective_request_delay_seconds=effective_delay, ) - 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( + + paging_kwargs = self._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 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, + ) + 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, + ) + + _, run, scholars, start_cstart_map = await self._initialize_run_for_user( db_session, user_id=user_id, trigger_type=trigger_type, @@ -1327,6 +1459,224 @@ class ScholarIngestionService: **paging_kwargs, **threshold_kwargs, ) + 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: + async with session_factory() as db_session: + try: + # Re-fetch everything to ensure attachment to the new session + run, user_settings, attached_scholars = await self._prepare_execute_run( + db_session, run_id=run_id, user_id=user_id, scholars=scholars + ) + + paging_kwargs = self._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 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, + ) + 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, + ) + + progress = await self._run_scholar_iteration( + db_session, + 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_kwargs, + ) + + failure_summary, alert_summary = self._complete_run_for_user( + user_settings=user_settings, + run=run, + scholars=attached_scholars, + user_id=user_id, + progress=progress, + idempotency_key=idempotency_key, + **threshold_kwargs, + ) + + # Capture the final status that _finalize_run_record computed, + # then set RESOLVING so the UI shows enrichment is in progress. + intended_final_status = run.status + if intended_final_status not in (RunStatus.CANCELED,): + run.status = RunStatus.RESOLVING + await db_session.commit() + self._log_run_completed(user_id=user_id, run=run, scholars=attached_scholars, progress=progress, alert_summary=alert_summary, failure_summary=failure_summary) + + # Fire-and-forget enrichment in a separate background task + if intended_final_status not in (RunStatus.CANCELED,): + asyncio.create_task( + self._background_enrich( + session_factory, + 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() + logger.exception("ingestion.background_run_failed", extra={"event": "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: + 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(timezone.utc) + run_to_fail.error_log["terminal_exception"] = str(exc) + await cleanup_session.commit() + + async def _background_enrich( + self, + session_factory: Any, + *, + run_id: int, + intended_final_status: RunStatus, + openalex_api_key: str | None = None, + ) -> None: + """Run enrichment independently — failures don't affect run status.""" + try: + async with session_factory() as session: + await self._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() + logger.info( + "ingestion.background_enrichment_complete", + extra={"run_id": run_id, "final_status": str(intended_final_status)}, + ) + except Exception: + logger.exception( + "ingestion.background_enrichment_failed", + extra={"run_id": run_id}, + ) + # Still transition out of RESOLVING so the run doesn't stay stuck. + 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: + logger.exception( + "ingestion.background_enrichment_fallback_failed", + extra={"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, + ) -> RunExecutionSummary: + # Legacy/Synchronous trigger (used by tests or older flows) + 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_kwargs = self._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 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, + ) + + 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, + ) + progress = await self._run_scholar_iteration( db_session, run=run, @@ -1337,6 +1687,8 @@ class ScholarIngestionService: queue_delay_seconds=queue_delay_seconds, **paging_kwargs, ) + + user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=user_id) failure_summary, alert_summary = self._complete_run_for_user( user_settings=user_settings, run=run, @@ -1346,7 +1698,27 @@ class ScholarIngestionService: idempotency_key=idempotency_key, **threshold_kwargs, ) + + # Set RESOLVING during enrichment (synchronous path). + intended_final_status = run.status + if intended_final_status not in (RunStatus.CANCELED,): + run.status = RunStatus.RESOLVING await db_session.commit() + + try: + await self._enrich_pending_publications( + db_session, + run_id=run.id, + openalex_api_key=getattr(user_settings, "openalex_api_key", None), + ) + except Exception: + logger.exception("ingestion.enrichment_failed", extra={"run_id": run.id}) + + # Finalize to the intended status unless the run was canceled during resolving. + if run.status == RunStatus.RESOLVING: + run.status = intended_final_status + 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) @@ -1416,32 +1788,45 @@ class ScholarIngestionService: } @staticmethod - def _should_retry_network_page( + def _should_retry_page_fetch( *, parsed_page: ParsedProfilePage, - attempt_index: int, - max_attempts: int, + network_attempt_count: int, + rate_limit_attempt_count: int, + network_error_retries: int, + rate_limit_retries: int, ) -> bool: - return parsed_page.state == ParseState.NETWORK_ERROR and attempt_index < max_attempts - 1 + 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_retry_backoff( *, scholar_id: str, cstart: int, - attempt_index: int, - backoff: float, + network_attempt_count: int, + rate_limit_attempt_count: int, + retry_backoff_seconds: float, + rate_limit_backoff_seconds: float, state_reason: str, ) -> None: - sleep_seconds = backoff * (2**attempt_index) + 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 + 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, + "attempt_count": attempt_label, "sleep_seconds": sleep_seconds, "state_reason": state_reason, }, @@ -1457,39 +1842,57 @@ class ScholarIngestionService: 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]]]: - max_attempts = max(1, int(network_error_retries) + 1) - backoff = max(float(retry_backoff_seconds), 0.0) + network_attempts = 0 + rate_limit_attempts = 0 attempt_log: list[dict[str, Any]] = [] fetch_result: FetchResult | None = None parsed_page: ParsedProfilePage | None = None - for attempt_index in range(max_attempts): + while True: 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) + + if parsed_page.state == ParseState.NETWORK_ERROR: + network_attempts += 1 + total_attempts = network_attempts + elif parsed_page.state == ParseState.BLOCKED_OR_CAPTCHA and parsed_page.state_reason == "blocked_http_429_rate_limited": + rate_limit_attempts += 1 + total_attempts = rate_limit_attempts + else: + total_attempts = network_attempts + rate_limit_attempts + 1 + attempt_log.append( self._attempt_log_entry( - attempt=attempt_index + 1, + attempt=total_attempts, cstart=cstart, fetch_result=fetch_result, parsed_page=parsed_page, ) ) - if not self._should_retry_network_page( + + if not self._should_retry_page_fetch( parsed_page=parsed_page, - attempt_index=attempt_index, - max_attempts=max_attempts, + 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_retry_backoff( scholar_id=scholar_id, cstart=cstart, - attempt_index=attempt_index, - backoff=backoff, + 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, ) @@ -1591,6 +1994,7 @@ class ScholarIngestionService: pagination_truncated_reason=None, continuation_cstart=None, skipped_no_change=True, + discovered_publication_count=0, ) @staticmethod @@ -1619,6 +2023,7 @@ class ScholarIngestionService: pagination_truncated_reason=None, continuation_cstart=continuation_cstart, skipped_no_change=False, + discovered_publication_count=0, ) @staticmethod @@ -1685,6 +2090,8 @@ class ScholarIngestionService: 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)) @@ -1695,6 +2102,8 @@ class ScholarIngestionService: 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 @@ -1747,6 +2156,8 @@ class ScholarIngestionService: 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._fetch_and_parse_page_with_retry( scholar_id=scholar_id, @@ -1754,6 +2165,8 @@ class ScholarIngestionService: 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) @@ -1771,24 +2184,56 @@ class ScholarIngestionService: async def _paginate_loop( self, *, - scholar_id: str, + scholar: ScholarProfile, + run: CrawlRun, + db_session: AsyncSession, 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, ) -> None: + # Cross-page canonical dedup state; grows across all pages of this scholar. + seen_canonical: set[str] = set() + + if state.parsed_page.publications: + deduped_first = _dedupe_publication_candidates( + list(state.parsed_page.publications), seen_canonical=seen_canonical + ) + if deduped_first: + discovered_count = await self._upsert_profile_publications( + db_session, run=run, scholar=scholar, publications=deduped_first + ) + state.discovered_publication_count += discovered_count + while state.parsed_page.has_show_more_button: + await db_session.refresh(run) + if run.status == RunStatus.CANCELED: + logger.info( + "ingestion.pagination_canceled", + extra={"event": "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_id, + 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, @@ -1796,6 +2241,19 @@ class ScholarIngestionService: parsed_page=next_parsed_page, page_attempt_log=next_attempt_log, ) + + # Deduplicate against all publications already committed this run, + # then immediately commit the surviving candidates to the DB. + if next_parsed_page.publications: + deduped_next = _dedupe_publication_candidates( + list(next_parsed_page.publications), seen_canonical=seen_canonical + ) + if deduped_next: + discovered_count = await self._upsert_profile_publications( + db_session, run=run, scholar=scholar, publications=deduped_next + ) + state.discovered_publication_count += discovered_count + if self._handle_page_state_transition(state=state): return @@ -1822,6 +2280,7 @@ class ScholarIngestionService: 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( @@ -1859,16 +2318,33 @@ class ScholarIngestionService: ) 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: + async def _fetch_and_parse_all_pages_with_retry( + self, + *, + scholar: ScholarProfile, + run: CrawlRun, + db_session: AsyncSession, + 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 + ) -> 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, + 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, @@ -1889,13 +2365,17 @@ class ScholarIngestionService: page_logs=page_logs, ) await self._paginate_loop( - scholar_id=scholar_id, + 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, ) return self._result_from_pagination_state( state=state, @@ -1904,6 +2384,222 @@ class ScholarIngestionService: first_page_fingerprint_sha256=first_page_fingerprint_sha256, ) + 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 _enrich_pending_publications( + self, + db_session: AsyncSession, + *, + run_id: int, + openalex_api_key: str | None = None, + ) -> None: + """Enrich unenriched publications with OpenAlex data. + + Stops immediately on budget exhaustion (429 with $0 remaining). + Sleeps 60s and continues on transient rate limits. + """ + from app.services.domains.openalex.client import ( + OpenAlexBudgetExhaustedError, + OpenAlexClient, + OpenAlexRateLimitError, + ) + from app.services.domains.openalex.matching import find_best_match + + run_result = await db_session.execute( + select(CrawlRun.user_id).where(CrawlRun.id == run_id) + ) + user_id = run_result.scalar_one() + + now = datetime.now(timezone.utc) + cooldown_threshold = now - 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) + publications = list(result.scalars().all()) + + 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 + + for i in range(0, len(publications), batch_size): + if await self._run_is_canceled(db_session, run_id=run_id): + logger.info("ingestion.enrichment_aborted", extra={"run_id": run_id}) + return + batch = publications[i : i + batch_size] + titles = [ + " ".join(re.sub(r"[^\w\s]", " ", p.title_raw).split()) + for p in batch + if p.title_raw and p.title_raw.strip() + ] + + if not titles: + continue + + try: + openalex_works = await client.get_works_by_filter( + {"title.search": "|".join(titles)}, limit=batch_size * 3 + ) + except OpenAlexBudgetExhaustedError: + logger.warning( + "ingestion.openalex_budget_exhausted", + extra={"event": "ingestion.openalex_budget_exhausted", "run_id": run_id}, + ) + break # Stop all enrichment — budget won't reset until midnight UTC + except OpenAlexRateLimitError: + logger.warning( + "ingestion.openalex_rate_limited", + extra={"event": "ingestion.openalex_rate_limited", "run_id": run_id}, + ) + await asyncio.sleep(60) + continue + except Exception as e: + logger.warning( + "ingestion.openalex_enrichment_failed", + extra={"event": "ingestion.openalex_enrichment_failed", "error": str(e), "run_id": run_id}, + ) + continue + + for p in batch: + # Check for cancellation periodically within the batch + if await self._run_is_canceled(db_session, run_id=run_id): + logger.info("ingestion.enrichment_aborted", extra={"run_id": run_id}) + return + + p.openalex_last_attempt_at = now + + # Perform identifier discovery (moved from synchronous ingest loop) + await identifier_service.discover_and_sync_identifiers_for_publication( + db_session, + publication=p, + scholar_label=p.author_text or "", + ) + + 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 or p.year + p.citation_count = match.cited_by_count or p.citation_count + p.pdf_url = match.oa_url or p.pdf_url + p.openalex_enriched = True + + await db_session.flush() + + from app.services.domains.publications.dedup import sweep_identifier_duplicates + + merge_count = await sweep_identifier_duplicates(db_session) + if merge_count: + logger.info( + "ingestion.identifier_dedup_sweep", + extra={ + "event": "ingestion.identifier_dedup_sweep", + "merged_count": merge_count, + "run_id": run_id, + }, + ) + + async def _enrich_publications_with_openalex( + self, + scholar: ScholarProfile, + publications: list[PublicationCandidate], + ) -> list[PublicationCandidate]: + if not publications: + return publications + + from app.services.domains.openalex.client import OpenAlexClient + from app.services.domains.openalex.matching import find_best_match + + client = OpenAlexClient(api_key=settings.openalex_api_key, mailto=settings.crossref_api_mailto) + + # Batch requests by 25 to stay within URL length limits + batch_size = 25 + enriched: list[PublicationCandidate] = [] + + import re + for i in range(0, len(publications), batch_size): + batch = publications[i:i + batch_size] + + titles = [] + for p in batch: + if not p.title: + continue + # Strip all non-alphanumeric words to prevent OpenAlex API injection crashes + # and minimize punctuation-based duplicate misses. + safe_title = re.sub(r"[^\w\s]", " ", p.title) + safe_title = " ".join(safe_title.split()) + if safe_title: + titles.append(safe_title) + + if not titles: + enriched.extend(batch) + continue + + query = "|".join(t for t in titles) + try: + openalex_works = await client.get_works_by_filter( + {"title.search": query}, limit=batch_size * 3 + ) + except Exception as e: + logger.warning( + "ingestion.openalex_enrichment_failed", + extra={"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 or p.year, + citation_count=match.cited_by_count, + authors_text=p.authors_text, + venue_text=p.venue_text, + pdf_url=match.oa_url or p.pdf_url, + ) + enriched.append(new_p) + else: + enriched.append(p) + return enriched + async def _upsert_profile_publications( self, db_session: AsyncSession, @@ -1912,6 +2608,7 @@ class ScholarIngestionService: scholar: ScholarProfile, publications: list[PublicationCandidate], ) -> int: + # We no longer enrich inline here. Enrichment is now deferred to the end of the run. seen_publication_ids: set[int] = set() discovered_count = 0 @@ -1949,12 +2646,43 @@ class ScholarIngestionService: "crawl_run_id": run.id, }, ) + + await self._commit_discovered_publication( + db_session, + run=run, + scholar=scholar, + publication=publication, + ) if not scholar.baseline_completed: scholar.baseline_completed = True return discovered_count + async def _commit_discovered_publication( + self, + 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.commit() + 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(timezone.utc).isoformat(), + "new_publication_count": int(run.new_pub_count or 0), + }, + ) + @staticmethod def _validate_publication_candidate(candidate: PublicationCandidate) -> None: if not candidate.title.strip(): @@ -1996,6 +2724,24 @@ class ScholarIngestionService: return cluster_publication return fingerprint_publication + @staticmethod + 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( + self, + 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( self, db_session: AsyncSession, @@ -2008,12 +2754,12 @@ class ScholarIngestionService: fingerprint_sha256=fingerprint, title_raw=candidate.title, title_normalized=normalize_title(candidate.title), + canonical_title_hash=self._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), - doi=first_doi_from_texts(candidate.title_url, candidate.venue_text, candidate.title), pdf_url=None, ) db_session.add(publication) @@ -2049,8 +2795,6 @@ class ScholarIngestionService: 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, @@ -2071,12 +2815,21 @@ class ScholarIngestionService: cluster_publication=cluster_publication, fingerprint_publication=fingerprint_publication, ) + if publication is None: + # Fallback: canonical title hash — catches cross-scholar noise variants + # (e.g. "Adam preprint (2014)" vs "Adam arXiv 2017" → same normalized form) + canonical_hash = self._compute_canonical_title_hash(candidate.title) + publication = await self._find_publication_by_canonical_title_hash( + db_session, + canonical_title_hash=canonical_hash, + ) if publication is None: created = await self._create_publication( db_session, candidate=candidate, fingerprint=fingerprint, ) + # Sync identifiers from local fields only for fast UI response await identifier_service.sync_identifiers_for_publication_fields( db_session, publication=created, @@ -2086,6 +2839,7 @@ class ScholarIngestionService: publication=publication, candidate=candidate, ) + # Sync identifiers from local fields only for fast UI response await identifier_service.sync_identifiers_for_publication_fields( db_session, publication=publication, diff --git a/app/services/domains/ingestion/fingerprints.py b/app/services/domains/ingestion/fingerprints.py index cf3e796..30745b0 100644 --- a/app/services/domains/ingestion/fingerprints.py +++ b/app/services/domains/ingestion/fingerprints.py @@ -15,12 +15,59 @@ from app.services.domains.ingestion.constants import ( ) from app.services.domains.scholar.parser import ParseState, ParsedProfilePage, 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*$") +# Strips ". Capitalised sentence" appended as venue: ". Comput. Sci…", ". Journal of…" +_NOISE_VENUE_SENTENCE_RE = re.compile(r"(?<=\w{3})\.\s+[A-Z][a-z].*$") + +_CANONICAL_DEDUP_THRESHOLD = 0.82 + def normalize_title(value: str) -> str: lowered = 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.""" + t = title.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_VENUE_SENTENCE_RE.sub("", t) + return normalize_title(t.strip()) + + +def _stripped_title_for_canonical(title: str) -> str: + """Apply noise-stripping and lowercase but PRESERVE spaces (for later tokenization).""" + t = title.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_VENUE_SENTENCE_RE.sub("", t) + return t.lower().strip() + + +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 "" @@ -100,31 +147,91 @@ def _next_cstart_value(*, articles_range: str | None, fallback: int) -> int: 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: 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: + 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 - seen.add(identity) - deduped.append(publication) + + # 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: + for existing in seen: + if _jaccard(tokens, existing) >= _CANONICAL_DEDUP_THRESHOLD: + return True + return False + + def _build_body_excerpt(body: str, *, max_chars: int = 220) -> str | None: if not body: return None diff --git a/app/services/domains/ingestion/scheduler.py b/app/services/domains/ingestion/scheduler.py index 9db0204..b05e84d 100644 --- a/app/services/domains/ingestion/scheduler.py +++ b/app/services/domains/ingestion/scheduler.py @@ -146,6 +146,9 @@ class SchedulerService: async def _tick_once(self) -> None: if self._continuation_queue_enabled: await self._drain_continuation_queue() + + await self._drain_pdf_queue() + candidates = await self._load_candidates() if not candidates: return @@ -488,6 +491,8 @@ class SchedulerService: 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}, @@ -583,6 +588,27 @@ class SchedulerService: return await self._finalize_queue_job_after_run(job, run_summary) + async def _drain_pdf_queue(self) -> None: + from app.services.domains.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: + logger.info("scheduler.pdf_queue_drain_completed", extra={ + "event": "scheduler.pdf_queue_drain_completed", + "processed_count": processed, + }) + except Exception: + logger.exception("scheduler.pdf_queue_drain_failed", extra={ + "event": "scheduler.pdf_queue_drain_failed", + }) + async def _load_request_delay_for_user( self, db_session: AsyncSession, diff --git a/app/services/domains/ingestion/types.py b/app/services/domains/ingestion/types.py index f6de578..09d87f1 100644 --- a/app/services/domains/ingestion/types.py +++ b/app/services/domains/ingestion/types.py @@ -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/domains/openalex/__init__.py b/app/services/domains/openalex/__init__.py new file mode 100644 index 0000000..a51555a --- /dev/null +++ b/app/services/domains/openalex/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/app/services/domains/openalex/client.py b/app/services/domains/openalex/client.py new file mode 100644 index 0000000..a85b630 --- /dev/null +++ b/app/services/domains/openalex/client.py @@ -0,0 +1,151 @@ +import asyncio +import logging +from typing import Any, Mapping + +import httpx +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from app.services.domains.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: + logger.warning("OpenAlex API error: %s %s", response.status_code, response.text) + 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: + logger.warning("OpenAlex API error (filters=%s): %s %s", filters, response.status_code, response.text) + 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 e: + logger.warning("Failed to parse OpenAlex raw dict: %s", e) + continue + + return parsed_works diff --git a/app/services/domains/openalex/matching.py b/app/services/domains/openalex/matching.py new file mode 100644 index 0000000..cfa511c --- /dev/null +++ b/app/services/domains/openalex/matching.py @@ -0,0 +1,108 @@ +import logging +import re + +from rapidfuzz import fuzz + +from app.services.domains.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: + if 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/domains/openalex/types.py b/app/services/domains/openalex/types.py new file mode 100644 index 0000000..0d86754 --- /dev/null +++ b/app/services/domains/openalex/types.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Mapping + + +@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/domains/portability/exporting.py b/app/services/domains/portability/exporting.py index ef43a89..5c89006 100644 --- a/app/services/domains/portability/exporting.py +++ b/app/services/domains/portability/exporting.py @@ -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), } @@ -75,7 +73,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/publication_import.py b/app/services/domains/portability/publication_import.py index daa1d5d..7654586 100644 --- a/app/services/domains/portability/publication_import.py +++ b/app/services/domains/portability/publication_import.py @@ -71,7 +71,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 +97,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 +113,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 +125,6 @@ def _new_publication( author_text=author_text, venue_text=venue_text, pub_url=pub_url, - doi=doi, pdf_url=pdf_url, ) @@ -285,7 +279,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 +299,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/publication_identifiers/application.py b/app/services/domains/publication_identifiers/application.py index d34e757..ca57d57 100644 --- a/app/services/domains/publication_identifiers/application.py +++ b/app/services/domains/publication_identifiers/application.py @@ -21,7 +21,7 @@ from app.services.domains.publication_identifiers.types import ( if TYPE_CHECKING: from app.services.domains.publications.pdf_queue import PdfQueueListItem - from app.services.domains.publications.types import PublicationListItem + from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem CONFIDENCE_HIGH = 0.98 CONFIDENCE_MEDIUM = 0.9 @@ -117,9 +117,76 @@ async def sync_identifiers_for_publication_fields( 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) + + existing_doi = await _existing_identifier_by_kind( + db_session, + publication_id=int(publication.id), + kind=IdentifierKind.DOI.value, + ) + if existing_doi is not None: + return + + from app.services.domains.crossref import application as crossref_service + from app.services.domains.publications.types import UnreadPublicationItem + + item = 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, + ) + + discovered_doi = await crossref_service.discover_doi_for_publication(item=item) + if discovered_doi: + normalized_doi = normalize_doi(discovered_doi) + if normalized_doi: + candidate = _candidate( + IdentifierKind.DOI, + discovered_doi, + normalized_doi, + "crossref_api", + CONFIDENCE_MEDIUM, + None, + ) + await _upsert_publication_candidate(db_session, publication_id=int(publication.id), candidate=candidate) + + existing_arxiv = await _existing_identifier_by_kind( + db_session, + publication_id=int(publication.id), + kind=IdentifierKind.ARXIV.value, + ) + if existing_arxiv is None: + from app.services.domains.arxiv import application as arxiv_service + discovered_arxiv = await arxiv_service.discover_arxiv_id_for_publication(item=item) + if discovered_arxiv: + normalized_arxiv = normalize_arxiv_id(discovered_arxiv) + if normalized_arxiv: + candidate = _candidate( + IdentifierKind.ARXIV, + discovered_arxiv, + normalized_arxiv, + "arxiv_api", + CONFIDENCE_MEDIUM, + None, + ) + await _upsert_publication_candidate(db_session, publication_id=int(publication.id), candidate=candidate) + + def _publication_field_candidates(publication: Publication) -> list[IdentifierCandidate]: return _fallback_candidates_from_values( - doi=publication.doi, + doi=None, pub_url=publication.pub_url, pdf_url=publication.pdf_url, ) @@ -194,6 +261,21 @@ async def _existing_identifier( 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, @@ -234,7 +316,7 @@ def _overlay_publication_item( item: PublicationListItem, display_identifier: DisplayIdentifier | None, ) -> PublicationListItem: - fallback = display_identifier or derive_display_identifier_from_values(doi=item.doi, pub_url=item.pub_url, pdf_url=item.pdf_url) + 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) @@ -253,7 +335,7 @@ def _overlay_queue_item( item: PdfQueueListItem, display_identifier: DisplayIdentifier | None, ) -> PdfQueueListItem: - fallback = display_identifier or derive_display_identifier_from_values(doi=item.doi, pdf_url=item.pdf_url) + fallback = display_identifier or derive_display_identifier_from_values(doi=None, pdf_url=item.pdf_url) return replace(item, display_identifier=fallback) diff --git a/app/services/domains/publication_identifiers/normalize.py b/app/services/domains/publication_identifiers/normalize.py index 50036be..9f7484a 100644 --- a/app/services/domains/publication_identifiers/normalize.py +++ b/app/services/domains/publication_identifiers/normalize.py @@ -7,7 +7,7 @@ from app.services.domains.doi.normalize import normalize_doi from app.services.domains.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_PATH_RE = re.compile(r"^/(?:abs|pdf)/([a-z-]+/\d{7}|\d{4}\.\d{4,5})(v\d+)?(?:\.pdf)?/?$", 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+)/?$") diff --git a/app/services/domains/publications/application.py b/app/services/domains/publications/application.py index b1161fb..f51c79a 100644 --- a/app/services/domains/publications/application.py +++ b/app/services/domains/publications/application.py @@ -24,7 +24,7 @@ from app.services.domains.publications.modes import ( resolve_publication_view_mode, ) from app.services.domains.publications.queries import ( - get_latest_completed_run_id_for_user, + get_latest_run_id_for_user, get_publication_item_for_user, publications_query, ) @@ -50,7 +50,7 @@ __all__ = [ "PublicationListItem", "UnreadPublicationItem", "resolve_publication_view_mode", - "get_latest_completed_run_id_for_user", + "get_latest_run_id_for_user", "publications_query", "get_publication_item_for_user", "list_for_user", diff --git a/app/services/domains/publications/counts.py b/app/services/domains/publications/counts.py index 8f65172..51a524d 100644 --- a/app/services/domains/publications/counts.py +++ b/app/services/domains/publications/counts.py @@ -1,6 +1,8 @@ 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 @@ -10,7 +12,7 @@ from app.services.domains.publications.modes import ( MODE_UNREAD, resolve_publication_view_mode, ) -from app.services.domains.publications.queries import get_latest_completed_run_id_for_user +from app.services.domains.publications.queries import get_latest_run_id_for_user async def count_for_user( @@ -20,11 +22,12 @@ async def count_for_user( mode: str = MODE_ALL, scholar_profile_id: int | None = None, favorite_only: bool = False, + 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) .where(ScholarProfile.user_id == user_id) @@ -33,6 +36,8 @@ async def count_for_user( 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: @@ -49,6 +54,7 @@ async def count_unread_for_user( user_id: int, scholar_profile_id: int | None = None, favorite_only: bool = False, + snapshot_before: datetime | None = None, ) -> int: return await count_for_user( db_session, @@ -56,6 +62,7 @@ async def count_unread_for_user( mode=MODE_UNREAD, scholar_profile_id=scholar_profile_id, favorite_only=favorite_only, + snapshot_before=snapshot_before, ) @@ -65,6 +72,7 @@ async def count_latest_for_user( user_id: int, scholar_profile_id: int | None = None, favorite_only: bool = False, + snapshot_before: datetime | None = None, ) -> int: return await count_for_user( db_session, @@ -72,6 +80,7 @@ async def count_latest_for_user( mode=MODE_LATEST, scholar_profile_id=scholar_profile_id, favorite_only=favorite_only, + snapshot_before=snapshot_before, ) @@ -80,6 +89,7 @@ async def count_favorite_for_user( *, user_id: int, scholar_profile_id: int | None = None, + snapshot_before: datetime | None = None, ) -> int: return await count_for_user( db_session, @@ -87,4 +97,5 @@ async def count_favorite_for_user( mode=MODE_ALL, scholar_profile_id=scholar_profile_id, favorite_only=True, + snapshot_before=snapshot_before, ) diff --git a/app/services/domains/publications/dedup.py b/app/services/domains/publications/dedup.py new file mode 100644 index 0000000..8f3c7b7 --- /dev/null +++ b/app/services/domains/publications/dedup.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import logging + +from sqlalchemy import delete, select +from sqlalchemy.orm import aliased +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Publication, PublicationIdentifier, ScholarPublication + +logger = logging.getLogger(__name__) + + +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. + + Winner is always the lower publication_id (earlier-created). Uses the existing + ix_publication_identifiers_kind_value index for the self-join. + """ + 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 scholar links, then delete the dup.""" + await _migrate_scholar_links(db_session, winner_id=winner_id, dup_id=dup_id) + await db_session.execute( + delete(Publication).where(Publication.id == dup_id) + ) + logger.info( + "publications.identifier_merge", + extra={ + "event": "publications.identifier_merge", + "winner_id": winner_id, + "dup_id": dup_id, + }, + ) + + +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 sweep_identifier_duplicates(db_session: AsyncSession) -> int: + """Find publications sharing an identifier and merge duplicates into the winner. + + Returns the number of duplicate publications removed. + """ + pairs = await find_identifier_duplicate_pairs(db_session) + if not pairs: + return 0 + + # Deduplicate the pairs — a dup may appear multiple times if it shares + # several identifiers with the winner; process each dup only once. + 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) diff --git a/app/services/domains/publications/listing.py b/app/services/domains/publications/listing.py index 0d09d4c..5290f6f 100644 --- a/app/services/domains/publications/listing.py +++ b/app/services/domains/publications/listing.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import datetime + from sqlalchemy.ext.asyncio import AsyncSession from app.services.domains.publication_identifiers import application as identifier_service @@ -9,7 +11,7 @@ from app.services.domains.publications.modes import ( resolve_publication_view_mode, ) from app.services.domains.publications.queries import ( - get_latest_completed_run_id_for_user, + get_latest_run_id_for_user, get_publication_item_for_user, publication_list_item_from_row, publications_query, @@ -25,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, @@ -37,8 +43,12 @@ 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 = [ diff --git a/app/services/domains/publications/pdf_queue.py b/app/services/domains/publications/pdf_queue.py index 3972f7d..7503138 100644 --- a/app/services/domains/publications/pdf_queue.py +++ b/app/services/domains/publications/pdf_queue.py @@ -2,10 +2,10 @@ from __future__ import annotations import asyncio from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import logging -from sqlalchemy import Select, func, literal, or_, select, union_all +from sqlalchemy import Select, and_, func, literal, or_, select, union_all from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import ( @@ -20,7 +20,6 @@ from app.db.session import get_session_factory from app.services.domains.publication_identifiers import application as identifier_service from app.services.domains.publication_identifiers.types import DisplayIdentifier from app.services.domains.publications.pdf_resolution_pipeline import ( - PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE, resolve_publication_pdf_outcome_for_row, ) from app.services.domains.publications.types import PublicationListItem @@ -49,7 +48,6 @@ _scheduled_tasks: set[asyncio.Task[None]] = set() class PdfQueueListItem: publication_id: int title: str - doi: str | None pdf_url: str | None status: str attempt_count: int @@ -114,7 +112,6 @@ def _item_from_row_and_job( 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, @@ -360,7 +357,7 @@ def _failed_outcome( ) -> OaResolutionOutcome: return OaResolutionOutcome( publication_id=row.publication_id, - doi=row.doi, + doi=None, pdf_url=None, failure_reason=FAILURE_RESOLUTION_EXCEPTION, source=None, @@ -372,10 +369,12 @@ async def _fetch_outcome_for_row( *, row: PublicationListItem, request_email: str | None, + openalex_api_key: str | None = None, ) -> OaResolutionOutcome: pipeline_result = await resolve_publication_pdf_outcome_for_row( row=row, request_email=request_email, + openalex_api_key=openalex_api_key, ) outcome = pipeline_result.outcome if outcome is not None: @@ -386,11 +385,8 @@ async def _fetch_outcome_for_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 @@ -426,7 +422,7 @@ async def _persist_outcome( 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_publication_update(publication, pdf_url=outcome.pdf_url) await identifier_service.sync_identifiers_for_publication_resolution( db_session, publication=publication, @@ -453,10 +449,26 @@ async def _resolve_publication_row( user_id: int, request_email: str | None, row: PublicationListItem, + openalex_api_key: str | None = None, ) -> None: + from app.services.domains.openalex.client import OpenAlexBudgetExhaustedError + from app.services.domains.arxiv.application import ArxivRateLimitError 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) + outcome = await _fetch_outcome_for_row( + row=row, + request_email=request_email, + openalex_api_key=openalex_api_key, + ) + except (OpenAlexBudgetExhaustedError, ArxivRateLimitError): + # Persist a terminal outcome so jobs do not remain stuck in "running". + await _persist_outcome( + publication_id=row.publication_id, + user_id=user_id, + outcome=_failed_outcome(row=row), + ) + # Propagate upward so the batch loop can stop immediately. + raise except Exception as exc: # pragma: no cover - defensive network boundary logger.warning( "publications.pdf_queue.resolve_failed", @@ -480,12 +492,44 @@ async def _run_resolution_task( request_email: str | None, rows: list[PublicationListItem], ) -> None: + from app.services.domains.openalex.client import OpenAlexBudgetExhaustedError + from app.services.domains.arxiv.application import ArxivRateLimitError + from app.services.domains.settings import application as user_settings_service + + # Resolve the best available API key: per-user setting → env var fallback. + 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 + for row in rows: - await _resolve_publication_row( - user_id=user_id, - request_email=request_email, - row=row, - ) + try: + await _resolve_publication_row( + user_id=user_id, + request_email=request_email, + row=row, + openalex_api_key=openalex_api_key, + ) + except OpenAlexBudgetExhaustedError: + logger.warning( + "publications.pdf_queue.budget_exhausted", + extra={"event": "publications.pdf_queue.budget_exhausted", + "detail": "Stopping PDF resolution batch — OpenAlex daily budget exhausted"}, + ) + break + except ArxivRateLimitError: + logger.warning( + "publications.pdf_queue.arxiv_rate_limited", + extra={ + "event": "publications.pdf_queue.arxiv_rate_limited", + "detail": "Stopping PDF resolution batch — arXiv rate limit hit (429)", + }, + ) + break def _schedule_rows( @@ -571,7 +615,6 @@ def _retry_item_from_publication( 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, @@ -629,13 +672,12 @@ def _queue_candidate_from_publication(publication: Publication) -> PublicationLi return PublicationListItem( publication_id=int(publication.id), scholar_profile_id=0, - scholar_label="admin", + 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=True, first_seen_at=publication.created_at or _utcnow(), @@ -649,6 +691,9 @@ async def _missing_pdf_candidates( limit: int, ) -> list[PublicationListItem]: bounded_limit = max(1, min(int(limit), 5000)) + now = datetime.now(timezone.utc) + cooldown_threshold = now - timedelta(days=7) + result = await db_session.execute( select(Publication) .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) @@ -656,7 +701,13 @@ async def _missing_pdf_candidates( .where( or_( PublicationPdfJob.publication_id.is_(None), - PublicationPdfJob.status.notin_([PDF_STATUS_QUEUED, PDF_STATUS_RUNNING]), + 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()) @@ -694,7 +745,6 @@ def _tracked_queue_select_base(*, status: str | None) -> Select[tuple]: select( PublicationPdfJob.publication_id, Publication.title_raw, - Publication.doi, Publication.pdf_url, PublicationPdfJob.status, PublicationPdfJob.attempt_count, @@ -730,7 +780,6 @@ def _untracked_queue_select_base() -> Select[tuple]: select( Publication.id, Publication.title_raw, - Publication.doi, Publication.pdf_url, literal(PDF_STATUS_UNTRACKED), literal(0), @@ -793,19 +842,18 @@ 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], + 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], ) @@ -902,3 +950,25 @@ async def list_pdf_queue_page( limit=bounded_limit, offset=bounded_offset, ) + + +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/domains/publications/pdf_resolution_pipeline.py b/app/services/domains/publications/pdf_resolution_pipeline.py index bc41257..0dc133c 100644 --- a/app/services/domains/publications/pdf_resolution_pipeline.py +++ b/app/services/domains/publications/pdf_resolution_pipeline.py @@ -2,100 +2,114 @@ from __future__ import annotations from dataclasses import dataclass import logging -from urllib.parse import urlparse - -import httpx +from typing import Any +from app.services.domains.arxiv.application import ArxivRateLimitError +from app.services.domains.openalex.client import OpenAlexBudgetExhaustedError from app.services.domains.publications.types import PublicationListItem -from app.services.domains.scholar.publication_pdf import ( - ScholarPublicationLinkCandidate, - ScholarPublicationLinkCandidates, - fetch_link_candidates_from_scholar_publication_page, -) -from app.services.domains.unpaywall import pdf_discovery as pdf_discovery_service from app.services.domains.unpaywall.application import OaResolutionOutcome, resolve_publication_oa_outcomes from app.settings import settings logger = logging.getLogger(__name__) -PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE = "scholar_publication_page" -PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE_UNLABELED = "scholar_publication_page_unlabeled_fallback" -PDF_PATH_TOKEN = "/pdf/" -HTTP_TIMEOUT_FLOOR_SECONDS = 0.5 - @dataclass(frozen=True) class PipelineOutcome: outcome: OaResolutionOutcome | None - scholar_candidates: ScholarPublicationLinkCandidates | None + scholar_candidates: Any | None # Kept for backward compatibility with calling signatures async def resolve_publication_pdf_outcome_for_row( *, row: PublicationListItem, request_email: str | None, + openalex_api_key: str | None = None, ) -> PipelineOutcome: - candidates = await _safe_scholar_candidates(row.pub_url) - labeled = _labeled_candidate(candidates) - if labeled is not None: - return PipelineOutcome(_scholar_outcome(row=row, candidate=labeled), candidates) + # 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_outcome = await _arxiv_outcome(row, request_email=request_email) + if arxiv_outcome and arxiv_outcome.pdf_url: + return PipelineOutcome(arxiv_outcome, None) + + # 3. Unpaywall (which falls back to Crossref) oa_outcome = await _oa_outcome(row=row, request_email=request_email) - if _oa_has_pdf(oa_outcome): - return PipelineOutcome(oa_outcome, candidates) - unlabeled = _unlabeled_candidate(candidates) - if unlabeled is None: - return PipelineOutcome(oa_outcome, candidates) - fallback_outcome = await _unlabeled_fallback_outcome(row=row, candidate=unlabeled) - if fallback_outcome is not None: - return PipelineOutcome(fallback_outcome, candidates) - return PipelineOutcome(oa_outcome, candidates) + return PipelineOutcome(oa_outcome, None) -async def _safe_scholar_candidates(pub_url: str | None) -> ScholarPublicationLinkCandidates | None: - try: - return await fetch_link_candidates_from_scholar_publication_page(pub_url) - except Exception as exc: # pragma: no cover - defensive boundary - logger.warning( - "publications.pdf_resolution.scholar_candidates_failed", - extra={"event": "publications.pdf_resolution.scholar_candidates_failed", "error": str(exc)}, - ) - return None - - -def _labeled_candidate( - candidates: ScholarPublicationLinkCandidates | None, -) -> ScholarPublicationLinkCandidate | None: - if candidates is None: - return None - return candidates.labeled_candidate - - -def _unlabeled_candidate( - candidates: ScholarPublicationLinkCandidates | None, -) -> ScholarPublicationLinkCandidate | None: - if candidates is None: - return None - return candidates.fallback_candidate - - -def _scholar_outcome( - *, +async def _openalex_outcome( row: PublicationListItem, - candidate: ScholarPublicationLinkCandidate, -) -> OaResolutionOutcome: - source = ( - PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE - if candidate.label_present - else PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE_UNLABELED - ) - return OaResolutionOutcome( - publication_id=row.publication_id, - doi=row.doi, - pdf_url=candidate.url, - failure_reason=None, - source=source, - used_crossref=False, - ) + request_email: str | None, + openalex_api_key: str | None = None, +) -> OaResolutionOutcome | None: + from app.services.domains.openalex.client import OpenAlexClient + from app.services.domains.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: + logger.warning( + "publications.pdf_resolution.openalex_failed", + extra={"event": "publications.pdf_resolution.openalex_failed", "error": str(exc)}, + ) + return None + + +async def _arxiv_outcome(row: PublicationListItem, request_email: str | None) -> OaResolutionOutcome | None: + from app.services.domains.arxiv.application import discover_arxiv_id_for_publication + + 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 the batch loop can stop + except Exception as exc: + logger.warning( + "publications.pdf_resolution.arxiv_failed", + extra={"event": "publications.pdf_resolution.arxiv_failed", "error": str(exc)}, + ) + return None async def _oa_outcome( @@ -105,46 +119,3 @@ async def _oa_outcome( ) -> OaResolutionOutcome | None: outcomes = await resolve_publication_oa_outcomes([row], request_email=request_email) return outcomes.get(row.publication_id) - - -def _oa_has_pdf(outcome: OaResolutionOutcome | None) -> bool: - return bool(outcome and outcome.pdf_url) - - -async def _unlabeled_fallback_outcome( - *, - row: PublicationListItem, - candidate: ScholarPublicationLinkCandidate, -) -> OaResolutionOutcome | None: - pdf_url = await _validated_pdf_url(candidate.url) - if pdf_url is None: - return None - return _scholar_outcome(row=row, candidate=ScholarPublicationLinkCandidate( - url=pdf_url, - confidence_score=candidate.confidence_score, - label_present=False, - reason=candidate.reason, - )) - - -async def _validated_pdf_url(candidate_url: str) -> str | None: - if _looks_direct_pdf(candidate_url): - return candidate_url - timeout_seconds = _discovery_timeout_seconds() - async with httpx.AsyncClient(timeout=timeout_seconds) as client: - if await pdf_discovery_service._candidate_is_pdf(client, candidate_url=candidate_url): - return candidate_url - return await pdf_discovery_service.resolve_pdf_from_landing_page(client, page_url=candidate_url) - - -def _looks_direct_pdf(url: str | None) -> bool: - if pdf_discovery_service.looks_like_pdf_url(url): - return True - if not isinstance(url, str): - return False - path = (urlparse(url).path or "").lower() - return PDF_PATH_TOKEN in path - - -def _discovery_timeout_seconds() -> float: - return max(float(settings.unpaywall_timeout_seconds), HTTP_TIMEOUT_FLOOR_SECONDS) diff --git a/app/services/domains/publications/queries.py b/app/services/domains/publications/queries.py index 292ec8e..6f57cab 100644 --- a/app/services/domains/publications/queries.py +++ b/app/services/domains/publications/queries.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import datetime + from sqlalchemy import Select, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,15 +17,24 @@ def _normalized_citation_count(value: object) -> int: return 0 -async def get_latest_completed_run_id_for_user( +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,7 +50,19 @@ 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]: + _SORT_COLUMNS = { + "first_seen": ScholarPublication.created_at, + "title": Publication.title_raw, + "year": Publication.year, + "citations": Publication.citation_count, + "scholar": ScholarProfile.display_name, + } + scholar_label = ScholarProfile.display_name stmt = ( select( @@ -52,7 +75,6 @@ def publications_query( Publication.citation_count, Publication.venue_text, Publication.pub_url, - Publication.doi, Publication.pdf_url, ScholarPublication.is_read, ScholarPublication.is_favorite, @@ -62,10 +84,15 @@ def publications_query( .join(ScholarPublication, ScholarPublication.publication_id == Publication.id) .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_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: @@ -76,6 +103,16 @@ def publications_query( if latest_run_id is None: return stmt.where(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_COLUMNS.get(sort_by, ScholarPublication.created_at) + 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 +133,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 +157,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, @@ -150,7 +186,6 @@ def publication_list_item_from_row( citation_count, venue_text, pub_url, - doi, pdf_url, is_read, is_favorite, @@ -166,7 +201,6 @@ 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), @@ -188,7 +222,6 @@ def unread_item_from_row(row: tuple) -> UnreadPublicationItem: citation_count, venue_text, pub_url, - doi, pdf_url, _is_read, _is_favorite, @@ -204,6 +237,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/types.py b/app/services/domains/publications/types.py index 3bde82d..62d038b 100644 --- a/app/services/domains/publications/types.py +++ b/app/services/domains/publications/types.py @@ -16,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 @@ -39,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/domains/runs/events.py b/app/services/domains/runs/events.py new file mode 100644 index 0000000..ef28069 --- /dev/null +++ b/app/services/domains/runs/events.py @@ -0,0 +1,59 @@ +import asyncio +import json +import logging +from typing import Any, AsyncGenerator, Dict, Set + +logger = logging.getLogger(__name__) + +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() + self._subscribers[run_id].add(queue) + logger.debug(f"New subscriber for run {run_id}. Total: {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: + logger.warning(f"Subscriber queue full for run {run_id}, dropping message") + +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() + # Server-Sent Events format: "event: \ndata: \n\n" + event_type = message["type"] + data_str = json.dumps(message["data"]) + yield f"event: {event_type}\ndata: {data_str}\n\n" + except asyncio.CancelledError: + logger.debug(f"Client disconnected from SSE stream for run {run_id}") + raise + finally: + run_events.unsubscribe(run_id, queue) diff --git a/app/services/domains/scholar/publication_pdf.py b/app/services/domains/scholar/publication_pdf.py deleted file mode 100644 index 6f8b6c1..0000000 --- a/app/services/domains/scholar/publication_pdf.py +++ /dev/null @@ -1,232 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from html.parser import HTMLParser -from urllib.parse import parse_qs, urlparse - -from app.services.domains.scholar.parser_types import ScholarDomInvariantError -from app.services.domains.scholar.parser_utils import attr_href, normalize_space -from app.services.domains.scholar.rate_limit import wait_for_scholar_slot -from app.services.domains.scholar.source import LiveScholarSource -from app.services.domains.settings.application import resolve_request_delay_minimum -from app.settings import settings - -CONTAINER_ID = "gsc_oci_title_gg" -PDF_LABEL_TOKEN = "[pdf]" -SCHOLAR_PDF_LABELED_CONFIDENCE = 0.98 -SCHOLAR_PDF_UNLABELED_CONFIDENCE = 0.2 -ALLOWED_URL_SCHEMES = frozenset({"http", "https"}) - - -@dataclass(frozen=True) -class ScholarPublicationLinkCandidate: - url: str - confidence_score: float - label_present: bool - reason: str - - -@dataclass(frozen=True) -class ScholarPublicationLinkCandidates: - container_seen: bool - labeled_candidate: ScholarPublicationLinkCandidate | None - fallback_candidate: ScholarPublicationLinkCandidate | None - warnings: tuple[str, ...] = () - - -@dataclass(frozen=True) -class _ParsedAnchor: - href: str | None - text: str - - -class _ScholarPublicationPdfParser(HTMLParser): - def __init__(self) -> None: - super().__init__(convert_charrefs=True) - self.container_seen = False - self.anchors: list[_ParsedAnchor] = [] - self._container_depth = 0 - self._anchor_depth = 0 - self._anchor_href: str | None = None - self._anchor_parts: list[str] = [] - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - self._increment_depths() - if self._starts_container(tag, attrs): - self.container_seen = True - self._container_depth = 1 - return - if self._container_depth <= 0 or tag != "a": - return - if self._anchor_depth > 0: - return - self._anchor_href = attr_href(attrs) - self._anchor_parts = [] - self._anchor_depth = 1 - - def handle_data(self, data: str) -> None: - if self._anchor_depth > 0: - self._anchor_parts.append(data) - - def handle_endtag(self, tag: str) -> None: - if self._anchor_depth > 0: - self._anchor_depth -= 1 - if self._anchor_depth == 0: - self._finish_anchor() - if self._container_depth > 0: - self._container_depth -= 1 - - def _increment_depths(self) -> None: - if self._container_depth > 0: - self._container_depth += 1 - if self._anchor_depth > 0: - self._anchor_depth += 1 - - def _starts_container(self, tag: str, attrs: list[tuple[str, str | None]]) -> bool: - if tag != "div": - return False - attrs_map = {name.lower(): (value or "") for name, value in attrs} - return attrs_map.get("id") == CONTAINER_ID - - def _finish_anchor(self) -> None: - self.anchors.append( - _ParsedAnchor( - href=self._anchor_href, - text=normalize_space("".join(self._anchor_parts)), - ) - ) - self._anchor_href = None - self._anchor_parts = [] - - -def is_scholar_publication_detail_url(url: str | None) -> bool: - if not isinstance(url, str) or not url.strip(): - return False - parsed = urlparse(url) - if parsed.scheme not in ALLOWED_URL_SCHEMES: - return False - if parsed.netloc.lower() != "scholar.google.com": - return False - query = parse_qs(parsed.query) - return _has_view_citation_params(query) - - -def _has_view_citation_params(query: dict[str, list[str]]) -> bool: - view_op = (query.get("view_op") or [""])[0] - citation = (query.get("citation_for_view") or [""])[0].strip() - return view_op == "view_citation" and bool(citation) - - -def extract_link_candidates_from_publication_detail_html(html: str) -> ScholarPublicationLinkCandidates: - parser = _parsed_publication_detail(html) - if not parser.container_seen: - return ScholarPublicationLinkCandidates(False, None, None) - anchors = _validated_container_anchors(parser.anchors) - labeled = _select_labeled_candidate(anchors) - fallback = _select_fallback_candidate(anchors, labeled=labeled) - warnings = _candidate_warnings(labeled=labeled, fallback=fallback) - return ScholarPublicationLinkCandidates(True, labeled, fallback, warnings) - - -def _parsed_publication_detail(html: str) -> _ScholarPublicationPdfParser: - parser = _ScholarPublicationPdfParser() - parser.feed(html) - parser.close() - return parser - - -def _validated_container_anchors(anchors: list[_ParsedAnchor]) -> list[_ParsedAnchor]: - if not anchors: - raise ScholarDomInvariantError( - code="layout_publication_link_container_missing_anchor", - message="Scholar publication link container was present without an anchor.", - ) - validated: list[_ParsedAnchor] = [] - for anchor in anchors: - validated.append(_validated_anchor(anchor)) - return validated - - -def _validated_anchor(anchor: _ParsedAnchor) -> _ParsedAnchor: - href = (anchor.href or "").strip() - if not href: - raise ScholarDomInvariantError( - code="layout_publication_link_missing_href", - message="Scholar publication link container anchor was missing href.", - ) - parsed = urlparse(href) - if parsed.scheme not in ALLOWED_URL_SCHEMES: - raise ScholarDomInvariantError( - code="layout_publication_link_invalid_scheme", - message="Scholar publication link used a non-http URL.", - ) - return _ParsedAnchor(href=href, text=anchor.text) - - -def _select_labeled_candidate(anchors: list[_ParsedAnchor]) -> ScholarPublicationLinkCandidate | None: - for anchor in anchors: - if PDF_LABEL_TOKEN in anchor.text.lower(): - return ScholarPublicationLinkCandidate( - url=str(anchor.href), - confidence_score=SCHOLAR_PDF_LABELED_CONFIDENCE, - label_present=True, - reason="scholar_link_labeled_pdf", - ) - return None - - -def _select_fallback_candidate( - anchors: list[_ParsedAnchor], - *, - labeled: ScholarPublicationLinkCandidate | None, -) -> ScholarPublicationLinkCandidate | None: - for anchor in anchors: - if labeled and anchor.href == labeled.url: - continue - return ScholarPublicationLinkCandidate( - url=str(anchor.href), - confidence_score=SCHOLAR_PDF_UNLABELED_CONFIDENCE, - label_present=False, - reason="scholar_link_unlabeled_fallback", - ) - if labeled is None and anchors: - anchor = anchors[0] - return ScholarPublicationLinkCandidate( - url=str(anchor.href), - confidence_score=SCHOLAR_PDF_UNLABELED_CONFIDENCE, - label_present=False, - reason="scholar_link_unlabeled_fallback", - ) - return None - - -def _candidate_warnings( - *, - labeled: ScholarPublicationLinkCandidate | None, - fallback: ScholarPublicationLinkCandidate | None, -) -> tuple[str, ...]: - warnings: list[str] = [] - if labeled is None and fallback is not None: - warnings.append("scholar_publication_link_unlabeled_only") - return tuple(warnings) - - -def _scholar_request_delay_seconds() -> int: - return resolve_request_delay_minimum(settings.ingestion_min_request_delay_seconds) - - -def _fetch_succeeded(fetch_result) -> bool: - return int(fetch_result.status_code or 0) == 200 and not fetch_result.error - - -async def fetch_link_candidates_from_scholar_publication_page( - publication_url: str | None, -) -> ScholarPublicationLinkCandidates | None: - if not is_scholar_publication_detail_url(publication_url): - return None - await wait_for_scholar_slot(min_interval_seconds=float(_scholar_request_delay_seconds())) - source = LiveScholarSource() - fetch_result = await source.fetch_publication_html(str(publication_url)) - if not _fetch_succeeded(fetch_result): - return None - return extract_link_candidates_from_publication_detail_html(fetch_result.body) diff --git a/app/services/domains/settings/application.py b/app/services/domains/settings/application.py index 3548e44..7f435e8 100644 --- a/app/services/domains/settings/application.py +++ b/app/services/domains/settings/application.py @@ -109,6 +109,8 @@ def parse_nav_visible_pages(value: object) -> list[str]: return deduped +from app.settings import settings as app_settings + async def get_or_create_settings( db_session: AsyncSession, *, @@ -121,7 +123,12 @@ async def get_or_create_settings( 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 +143,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/application.py b/app/services/domains/unpaywall/application.py index a658818..13104e9 100644 --- a/app/services/domains/unpaywall/application.py +++ b/app/services/domains/unpaywall/application.py @@ -63,7 +63,10 @@ def _extract_explicit_doi(text: str | None) -> str | None: def _publication_doi(item: PublicationListItem | UnreadPublicationItem) -> str | None: - stored = normalize_doi(item.doi) + stored = None + if getattr(item, "display_identifier", None) and item.display_identifier.kind == "doi": + stored = normalize_doi(item.display_identifier.value) + explicit_doi = ( _extract_explicit_doi(item.pub_url) or _extract_explicit_doi(item.venue_text) @@ -169,9 +172,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 @@ -427,7 +432,8 @@ async def resolve_publication_oa_outcomes( 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, diff --git a/app/settings.py b/app/settings.py index 43871cf..15b0c19 100644 --- a/app/settings.py +++ b/app/settings.py @@ -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( @@ -241,9 +247,9 @@ 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) @@ -252,6 +258,10 @@ class Settings: 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/build/lib/app/__init__.py b/build/lib/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/build/lib/app/__init__.py @@ -0,0 +1 @@ + diff --git a/build/lib/app/api/__init__.py b/build/lib/app/api/__init__.py new file mode 100644 index 0000000..3ce55a6 --- /dev/null +++ b/build/lib/app/api/__init__.py @@ -0,0 +1,2 @@ +from __future__ import annotations + diff --git a/build/lib/app/api/deps.py b/build/lib/app/api/deps.py new file mode 100644 index 0000000..f9c5377 --- /dev/null +++ b/build/lib/app/api/deps.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from fastapi import Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.errors import ApiException +from app.auth import runtime as auth_runtime +from app.db.models import User +from app.db.session import get_db_session + + +async def get_api_current_user( + request: Request, + db_session: AsyncSession = Depends(get_db_session), +) -> User: + current_user = await auth_runtime.get_authenticated_user(request, db_session) + if current_user is None: + raise ApiException( + status_code=401, + code="auth_required", + message="Authentication required.", + ) + return current_user + + +async def get_api_admin_user( + current_user: User = Depends(get_api_current_user), +) -> User: + if not current_user.is_admin: + raise ApiException( + status_code=403, + code="forbidden", + message="Admin access required.", + ) + return current_user diff --git a/build/lib/app/api/errors.py b/build/lib/app/api/errors.py new file mode 100644 index 0000000..976ccb8 --- /dev/null +++ b/build/lib/app/api/errors.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import FastAPI, HTTPException, Request +from fastapi.exception_handlers import ( + http_exception_handler as fastapi_http_exception_handler, +) +from fastapi.exception_handlers import ( + request_validation_exception_handler as fastapi_validation_exception_handler, +) +from fastapi.exceptions import RequestValidationError + +from app.api.responses import error_response + + +def _is_api_path(path: str) -> bool: + return path.startswith("/api/") + + +def _status_code_to_error_code(status_code: int) -> str: + mapping = { + 400: "bad_request", + 401: "auth_required", + 403: "forbidden", + 404: "not_found", + 409: "conflict", + 422: "validation_error", + 429: "rate_limited", + 500: "internal_error", + } + return mapping.get(status_code, "error") + + +class ApiException(Exception): + def __init__( + self, + *, + status_code: int, + code: str, + message: str, + details: Any | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.code = code + self.message = message + self.details = details + + +def register_api_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(ApiException) + async def _handle_api_exception(request: Request, exc: ApiException): + return error_response( + request, + status_code=exc.status_code, + code=exc.code, + message=exc.message, + details=exc.details, + ) + + @app.exception_handler(HTTPException) + async def _handle_http_exception(request: Request, exc: HTTPException): + if not _is_api_path(request.url.path): + return await fastapi_http_exception_handler(request, exc) + return error_response( + request, + status_code=exc.status_code, + code=_status_code_to_error_code(exc.status_code), + message=str(exc.detail) if exc.detail is not None else "Request failed.", + details=exc.detail if isinstance(exc.detail, (dict, list)) else None, + ) + + @app.exception_handler(RequestValidationError) + async def _handle_validation_exception(request: Request, exc: RequestValidationError): + if not _is_api_path(request.url.path): + return await fastapi_validation_exception_handler(request, exc) + return error_response( + request, + status_code=422, + code="validation_error", + message="Request validation failed.", + details=exc.errors(), + ) + diff --git a/build/lib/app/api/media.py b/build/lib/app/api/media.py new file mode 100644 index 0000000..00943bd --- /dev/null +++ b/build/lib/app/api/media.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import mimetypes + +from fastapi import APIRouter, Depends +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession + +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.settings import settings + +router = APIRouter(tags=["media"]) + + +@router.get("/scholar-images/{scholar_profile_id}/upload") +async def get_uploaded_scholar_image( + scholar_profile_id: int, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=current_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.", + ) + if not profile.profile_image_upload_path: + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) + + try: + image_path = scholar_service.resolve_upload_file_path( + upload_dir=settings.scholar_image_upload_dir, + relative_path=profile.profile_image_upload_path, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) from exc + + if not image_path.exists() or not image_path.is_file(): + raise ApiException( + status_code=404, + code="scholar_image_not_found", + message="Scholar image not found.", + ) + + media_type = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream" + return FileResponse(path=image_path, media_type=media_type) diff --git a/build/lib/app/api/responses.py b/build/lib/app/api/responses.py new file mode 100644 index 0000000..9976aba --- /dev/null +++ b/build/lib/app/api/responses.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Any + +from fastapi.encoders import jsonable_encoder +from starlette.requests import Request +from starlette.responses import JSONResponse + + +def _request_id(request: Request) -> str | None: + request_state = getattr(request, "state", None) + if request_state is None: + return None + return getattr(request_state, "request_id", None) + + +def success_payload( + request: Request, + *, + data: Any, +) -> dict[str, Any]: + return { + "data": data, + "meta": { + "request_id": _request_id(request), + }, + } + + +def success_response( + request: Request, + *, + data: Any, + status_code: int = 200, +) -> JSONResponse: + payload = success_payload(request, data=data) + return JSONResponse( + status_code=status_code, + content=jsonable_encoder(payload), + ) + + +def error_response( + request: Request, + *, + status_code: int, + code: str, + message: str, + details: Any | None = None, +) -> JSONResponse: + payload = { + "error": { + "code": code, + "message": message, + "details": details, + }, + "meta": { + "request_id": _request_id(request), + }, + } + return JSONResponse( + status_code=status_code, + content=jsonable_encoder(payload), + ) diff --git a/build/lib/app/api/router.py b/build/lib/app/api/router.py new file mode 100644 index 0000000..a11d98a --- /dev/null +++ b/build/lib/app/api/router.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from app.api.routers import admin, admin_dbops, auth, publications, runs, scholars, settings + +router = APIRouter(prefix="/api/v1") +router.include_router(auth.router) +router.include_router(admin.router) +router.include_router(admin_dbops.router) +router.include_router(scholars.router) +router.include_router(settings.router) +router.include_router(runs.router) +router.include_router(publications.router) diff --git a/build/lib/app/api/routers/__init__.py b/build/lib/app/api/routers/__init__.py new file mode 100644 index 0000000..3ce55a6 --- /dev/null +++ b/build/lib/app/api/routers/__init__.py @@ -0,0 +1,2 @@ +from __future__ import annotations + diff --git a/build/lib/app/api/routers/admin.py b/build/lib/app/api/routers/admin.py new file mode 100644 index 0000000..9844c3e --- /dev/null +++ b/build/lib/app/api/routers/admin.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession + +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 ( + AdminResetPasswordRequest, + AdminUserActiveUpdateRequest, + AdminUserCreateRequest, + AdminUserEnvelope, + AdminUsersListEnvelope, + MessageEnvelope, +) +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 + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/users", tags=["api-admin-users"]) + + +def _serialize_user(user: User) -> dict[str, object]: + return { + "id": int(user.id), + "email": user.email, + "is_active": bool(user.is_active), + "is_admin": bool(user.is_admin), + "created_at": user.created_at, + "updated_at": user.updated_at, + } + + +@router.get( + "", + response_model=AdminUsersListEnvelope, +) +async def list_users( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + 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), + }, + ) + return success_payload( + request, + data={ + "users": [_serialize_user(user) for user in users], + }, + ) + + +@router.post( + "", + response_model=AdminUserEnvelope, + status_code=201, +) +async def create_user( + payload: AdminUserCreateRequest, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + auth_service: AuthService = Depends(get_auth_service), + admin_user: User = Depends(get_api_admin_user), +): + try: + validated_email = user_service.validate_email(payload.email) + validated_password = user_service.validate_password(payload.password) + created_user = await user_service.create_user( + db_session, + email=validated_email, + password_hash=auth_service.hash_password(validated_password), + is_admin=bool(payload.is_admin), + ) + except user_service.UserServiceError as exc: + raise ApiException( + status_code=400, + code="invalid_user_input", + message=str(exc), + ) from exc + + 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), + }, + ) + return success_payload( + request, + data=_serialize_user(created_user), + ) + + +@router.patch( + "/{user_id}/active", + response_model=AdminUserEnvelope, +) +async def set_user_active( + user_id: int, + payload: AdminUserActiveUpdateRequest, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + target_user = await user_service.get_user_by_id(db_session, user_id) + if target_user is None: + raise ApiException( + status_code=404, + 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) + ): + raise ApiException( + status_code=400, + code="cannot_deactivate_self", + message="You cannot deactivate your own account.", + ) + updated_user = await user_service.set_user_active( + db_session, + user=target_user, + is_active=bool(payload.is_active), + ) + 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), + }, + ) + return success_payload( + request, + data=_serialize_user(updated_user), + ) + + +@router.post( + "/{user_id}/reset-password", + response_model=MessageEnvelope, +) +async def reset_user_password( + user_id: int, + payload: AdminResetPasswordRequest, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + auth_service: AuthService = Depends(get_auth_service), + admin_user: User = Depends(get_api_admin_user), +): + target_user = await user_service.get_user_by_id(db_session, user_id) + if target_user is None: + raise ApiException( + status_code=404, + code="user_not_found", + message="User not found.", + ) + try: + validated_password = user_service.validate_password(payload.new_password) + except user_service.UserServiceError as exc: + raise ApiException( + status_code=400, + code="invalid_password", + message=str(exc), + ) from exc + + await user_service.set_user_password_hash( + db_session, + user=target_user, + password_hash=auth_service.hash_password(validated_password), + ) + 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), + }, + ) + return success_payload( + request, + data={"message": f"Password reset: {target_user.email}"}, + ) diff --git a/build/lib/app/api/routers/admin_dbops.py b/build/lib/app/api/routers/admin_dbops.py new file mode 100644 index 0000000..87fc3b8 --- /dev/null +++ b/build/lib/app/api/routers/admin_dbops.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, Path, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +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 ( + AdminDbIntegrityEnvelope, + AdminPdfQueueBulkEnqueueEnvelope, + AdminPdfQueueRequeueEnvelope, + AdminPdfQueueEnvelope, + AdminDbRepairJobsEnvelope, + AdminRepairPublicationLinksEnvelope, + AdminRepairPublicationLinksRequest, +) +from app.db.models import DataRepairJob, User +from app.db.session import get_db_session +from app.services.domains.dbops import ( + collect_integrity_report, + list_repair_jobs, + run_publication_link_repair, +) +from app.services.domains.publications import application as publication_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/db", tags=["api-admin-dbops"]) + + +def _serialize_repair_job(job: DataRepairJob) -> dict[str, object]: + return { + "id": int(job.id), + "job_name": job.job_name, + "requested_by": job.requested_by, + "dry_run": bool(job.dry_run), + "status": job.status, + "scope": dict(job.scope or {}), + "summary": dict(job.summary or {}), + "error_text": job.error_text, + "started_at": job.started_at, + "finished_at": job.finished_at, + "created_at": job.created_at, + "updated_at": job.updated_at, + } + + +def _requested_by_value(*, payload: AdminRepairPublicationLinksRequest, admin_user: User) -> str: + from_payload = (payload.requested_by or "").strip() + return from_payload or admin_user.email + + +def _serialize_pdf_queue_item(item) -> dict[str, object]: + return { + "publication_id": item.publication_id, + "title": item.title, + "display_identifier": _serialize_display_identifier(item.display_identifier), + "pdf_url": item.pdf_url, + "status": item.status, + "attempt_count": item.attempt_count, + "last_failure_reason": item.last_failure_reason, + "last_failure_detail": item.last_failure_detail, + "last_source": item.last_source, + "requested_by_user_id": item.requested_by_user_id, + "requested_by_email": item.requested_by_email, + "queued_at": item.queued_at, + "last_attempt_at": item.last_attempt_at, + "resolved_at": item.resolved_at, + "updated_at": item.updated_at, + } + + +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." + return "blocked", "PDF lookup is already queued or currently running." + + +def _bulk_enqueue_message(*, queued_count: int, requested_count: int) -> str: + if requested_count == 0: + return "No missing-PDF publications were found." + if queued_count == 0: + return "No publications were queued; all candidates were already in-flight." + return f"Queued {queued_count} publication(s) for PDF lookup." + + +def _resolve_pdf_queue_paging( + *, + page: int, + page_size: int, + limit: int | None, + offset: int | None, +) -> tuple[int, int, int]: + resolved_limit = int(limit) if limit is not None else int(page_size) + resolved_offset = int(offset) if offset is not None else max((int(page) - 1) * resolved_limit, 0) + resolved_page = max((resolved_offset // max(resolved_limit, 1)) + 1, 1) + return resolved_page, max(resolved_limit, 1), max(resolved_offset, 0) + + +def _pdf_queue_page_data(*, total_count: int, page: int, page_size: int, offset: int) -> dict[str, object]: + return { + "total_count": int(total_count), + "page": int(page), + "page_size": int(page_size), + "has_prev": int(offset) > 0, + "has_next": int(offset) + int(page_size) < int(total_count), + } + + +@router.get( + "/integrity", + response_model=AdminDbIntegrityEnvelope, +) +async def get_integrity_report( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + report = await collect_integrity_report(db_session) + 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", [])), + }, + ) + return success_payload(request, data=report) + + +@router.get( + "/repair-jobs", + response_model=AdminDbRepairJobsEnvelope, +) +async def get_repair_jobs( + request: Request, + limit: int = Query(default=50, ge=1, le=200), + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + jobs = await list_repair_jobs(db_session, limit=limit) + 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), + }, + ) + return success_payload( + request, + data={"jobs": [_serialize_repair_job(job) for job in jobs]}, + ) + + +@router.get( + "/pdf-queue", + response_model=AdminPdfQueueEnvelope, +) +async def get_pdf_queue( + request: Request, + 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=500), + offset: int | None = Query(default=None, ge=0), + status: str | None = Query(default=None), + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + normalized_status = (status or "").strip().lower() or None + resolved_page, resolved_limit, resolved_offset = _resolve_pdf_queue_paging( + page=page, + page_size=page_size, + limit=limit, + offset=offset, + ) + queue_page = await publication_service.list_pdf_queue_page( + db_session, + limit=resolved_limit, + offset=resolved_offset, + status=normalized_status, + ) + 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), + }, + ) + return success_payload( + request, + data={ + "items": [_serialize_pdf_queue_item(item) for item in queue_page.items], + **_pdf_queue_page_data( + total_count=queue_page.total_count, + page=resolved_page, + page_size=resolved_limit, + offset=resolved_offset, + ), + }, + ) + + +@router.post( + "/pdf-queue/{publication_id}/requeue", + response_model=AdminPdfQueueRequeueEnvelope, +) +async def requeue_pdf_lookup( + request: Request, + publication_id: int = Path(ge=1), + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + result = await publication_service.enqueue_retry_pdf_job_for_publication_id( + db_session, + user_id=int(admin_user.id), + request_email=admin_user.email, + publication_id=publication_id, + ) + if not result.publication_exists: + raise ApiException( + status_code=404, + code="publication_not_found", + 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), + }, + ) + return success_payload( + request, + data={ + "publication_id": int(publication_id), + "queued": bool(result.queued), + "status": status, + "message": message, + }, + ) + + +@router.post( + "/pdf-queue/requeue-all", + response_model=AdminPdfQueueBulkEnqueueEnvelope, +) +async def requeue_all_missing_pdfs( + request: Request, + limit: int = Query(default=1000, ge=1, le=5000), + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + result = await publication_service.enqueue_all_missing_pdf_jobs( + db_session, + user_id=int(admin_user.id), + request_email=admin_user.email, + limit=limit, + ) + 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), + }, + ) + return success_payload( + request, + data={ + "requested_count": int(result.requested_count), + "queued_count": int(result.queued_count), + "message": _bulk_enqueue_message( + queued_count=int(result.queued_count), + requested_count=int(result.requested_count), + ), + }, + ) + + +@router.post( + "/repairs/publication-links", + response_model=AdminRepairPublicationLinksEnvelope, +) +async def trigger_publication_link_repair( + payload: AdminRepairPublicationLinksRequest, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + try: + result = await run_publication_link_repair( + db_session, + scope_mode=payload.scope_mode, + user_id=payload.user_id, + scholar_profile_ids=payload.scholar_profile_ids, + dry_run=bool(payload.dry_run), + gc_orphan_publications=bool(payload.gc_orphan_publications), + requested_by=_requested_by_value(payload=payload, admin_user=admin_user), + ) + except ValueError as exc: + raise ApiException( + status_code=400, + code="invalid_repair_scope", + message=str(exc), + ) from exc + 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"], + }, + ) + return success_payload(request, data=result) diff --git a/build/lib/app/api/routers/auth.py b/build/lib/app/api/routers/auth.py new file mode 100644 index 0000000..8f8da17 --- /dev/null +++ b/build/lib/app/api/routers/auth.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import logging + +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.responses import success_payload +from app.api.schemas import ( + AuthMeEnvelope, + ChangePasswordRequest, + CsrfBootstrapEnvelope, + LoginEnvelope, + LoginRequest, + MessageEnvelope, +) +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.security.csrf import ensure_csrf_token +from app.services.domains.users import application as user_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth", tags=["api-auth"]) + + +def _login_limiter_key_and_email(request: Request, payload: LoginRequest) -> tuple[str, str]: + return auth_runtime.login_rate_limit_key(request, payload.email), payload.email.strip().lower() + + +def _raise_rate_limited(normalized_email: str, retry_after_seconds: int) -> None: + logger.warning( + "api.auth.login_rate_limited", + extra={ + "event": "api.auth.login_rate_limited", + "email": normalized_email, + "retry_after_seconds": retry_after_seconds, + }, + ) + raise ApiException( + status_code=429, + code="rate_limited", + message="Too many login attempts. Please try again later.", + details={"retry_after_seconds": retry_after_seconds}, + ) + + +def _serialize_user_payload(user: User) -> dict[str, object]: + return { + "id": int(user.id), + "email": user.email, + "is_admin": bool(user.is_admin), + "is_active": bool(user.is_active), + } + + +def _raise_invalid_credentials(*, normalized_email: str) -> None: + logger.info( + "api.auth.login_failed", + extra={"event": "api.auth.login_failed", "email": normalized_email}, + ) + raise ApiException( + status_code=401, + code="invalid_credentials", + message="Invalid email or password.", + ) + + +@router.post( + "/login", + response_model=LoginEnvelope, +) +async def login( + payload: LoginRequest, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + auth_service: AuthService = Depends(get_auth_service), + rate_limiter: SlidingWindowRateLimiter = Depends(get_login_rate_limiter), +): + limiter_key, normalized_email = _login_limiter_key_and_email(request, payload) + decision = rate_limiter.check(limiter_key) + if not decision.allowed: + _raise_rate_limited(normalized_email, int(decision.retry_after_seconds)) + + user = await auth_service.authenticate_user( + db_session, + email=payload.email, + password=payload.password, + ) + if user is None: + rate_limiter.record_failure(limiter_key) + _raise_invalid_credentials(normalized_email=normalized_email) + + rate_limiter.reset(limiter_key) + set_session_user( + request, + user_id=user.id, + 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, + }, + ) + return success_payload( + request, + data={ + "authenticated": True, + "csrf_token": ensure_csrf_token(request), + "user": _serialize_user_payload(user), + }, + ) + + +@router.get( + "/me", + response_model=AuthMeEnvelope, +) +async def get_current_session( + request: Request, + current_user: User = Depends(get_api_current_user), +): + return success_payload( + request, + data={ + "authenticated": True, + "csrf_token": ensure_csrf_token(request), + "user": _serialize_user_payload(current_user), + }, + ) + + +@router.get( + "/csrf", + response_model=CsrfBootstrapEnvelope, +) +async def get_csrf_bootstrap( + request: Request, + db_session: AsyncSession = Depends(get_db_session), +): + current_user = await auth_runtime.get_authenticated_user(request, db_session) + return success_payload( + request, + data={ + "csrf_token": ensure_csrf_token(request), + "authenticated": current_user is not None, + }, + ) + + +@router.post( + "/change-password", + response_model=MessageEnvelope, +) +async def change_password( + payload: ChangePasswordRequest, + request: Request, + current_user: User = Depends(get_api_current_user), + db_session: AsyncSession = Depends(get_db_session), + auth_service: AuthService = Depends(get_auth_service), +): + if not auth_service.verify_password( + password_hash=current_user.password_hash, + password=payload.current_password, + ): + raise ApiException( + status_code=400, + code="invalid_current_password", + message="Current password is incorrect.", + ) + if payload.new_password != payload.confirm_password: + raise ApiException( + status_code=400, + code="password_confirmation_mismatch", + message="New password and confirmation do not match.", + ) + try: + validated_password = user_service.validate_password(payload.new_password) + except user_service.UserServiceError as exc: + raise ApiException( + status_code=400, + code="invalid_password", + message=str(exc), + ) from exc + + await user_service.set_user_password_hash( + db_session, + 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), + }, + ) + return success_payload( + request, + data={"message": "Password updated successfully."}, + ) + + +@router.post( + "/logout", + response_model=MessageEnvelope, +) +async def logout( + request: Request, + db_session: AsyncSession = Depends(get_db_session), +): + 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, + }, + ) + return success_payload( + request, + data={ + "message": "Logged out.", + }, + ) diff --git a/build/lib/app/api/routers/publications.py b/build/lib/app/api/routers/publications.py new file mode 100644 index 0000000..2a96b8b --- /dev/null +++ b/build/lib/app/api/routers/publications.py @@ -0,0 +1,528 @@ +from __future__ import annotations + +import logging +from typing import Literal + +from fastapi import APIRouter, Depends, Path, Query, Request +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.schemas import ( + MarkAllReadEnvelope, + MarkSelectedReadEnvelope, + MarkSelectedReadRequest, + PublicationsListEnvelope, + RetryPublicationPdfEnvelope, + RetryPublicationPdfRequest, + TogglePublicationFavoriteEnvelope, + TogglePublicationFavoriteRequest, +) +from app.db.models import User +from app.db.session import get_db_session +from app.services.domains.publication_identifiers import application as identifier_service +from app.services.domains.publications import application as publication_service +from app.services.domains.scholars import application as scholar_service +from app.settings import settings + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/publications", tags=["api-publications"]) + + +async def _require_selected_profile( + db_session: AsyncSession, + *, + user_id: int, + selected_scholar_id: int | None, +) -> None: + if selected_scholar_id is None: + return + selected_profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=user_id, + scholar_profile_id=selected_scholar_id, + ) + if selected_profile is None: + raise ApiException( + status_code=404, + code="scholar_not_found", + message="Scholar filter not found.", + ) + + +def _serialize_publication_item(item) -> dict[str, object]: + return { + "publication_id": item.publication_id, + "scholar_profile_id": item.scholar_profile_id, + "scholar_label": item.scholar_label, + "title": item.title, + "year": item.year, + "citation_count": item.citation_count, + "venue_text": item.venue_text, + "pub_url": item.pub_url, + "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, + "pdf_failure_reason": item.pdf_failure_reason, + "pdf_failure_detail": item.pdf_failure_detail, + "is_read": item.is_read, + "is_favorite": item.is_favorite, + "first_seen_at": item.first_seen_at, + "is_new_in_latest_run": item.is_new_in_latest_run, + } + + +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, +) -> 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, + ) + favorites_count = await publication_service.count_favorite_for_user( + db_session, + user_id=user_id, + scholar_profile_id=selected_scholar_id, + ) + 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, + ) + total_count = await publication_service.count_for_user( + db_session, + user_id=user_id, + mode=publication_service.MODE_ALL, + scholar_profile_id=selected_scholar_id, + favorite_only=favorite_only, + ) + return unread_count, favorites_count, latest_count, total_count + + +async def _list_publications_for_request( + db_session: AsyncSession, + *, + current_user: User, + mode: Literal["all", "unread", "latest", "new"] | None, + favorite_only: bool, + scholar_profile_id: int | None, + limit: int, + offset: int, +) -> tuple[str, int | None, list]: + resolved_mode = publication_service.resolve_publication_view_mode(mode) + selected_scholar_id = scholar_profile_id + await _require_selected_profile( + db_session, + user_id=current_user.id, + selected_scholar_id=selected_scholar_id, + ) + publications = await publication_service.list_for_user( + db_session, + user_id=current_user.id, + mode=resolved_mode, + scholar_profile_id=selected_scholar_id, + favorite_only=favorite_only, + limit=limit, + offset=offset, + ) + await publication_service.schedule_missing_pdf_enrichment_for_user( + db_session, + user_id=current_user.id, + request_email=current_user.email, + items=publications, + max_items=settings.unpaywall_max_items_per_request, + ) + hydrated = await publication_service.hydrate_pdf_enrichment_state( + db_session, + items=publications, + ) + return resolved_mode, selected_scholar_id, hydrated + + +def _resolve_publications_paging( + *, + page: int, + page_size: int, + limit: int | None, + offset: int | None, +) -> tuple[int, int, int]: + resolved_limit = int(limit) if limit is not None else int(page_size) + resolved_offset = int(offset) if offset is not None else max((int(page) - 1) * resolved_limit, 0) + resolved_page = max((resolved_offset // max(resolved_limit, 1)) + 1, 1) + return resolved_page, max(resolved_limit, 1), max(resolved_offset, 0) + + +def _publications_list_data( + *, + mode: str, + favorite_only: bool, + selected_scholar_id: int | None, + unread_count: int, + favorites_count: int, + latest_count: int, + total_count: int, + publications: list, + page: int, + page_size: int, + offset: int, +) -> dict[str, object]: + return { + "mode": mode, + "favorite_only": favorite_only, + "selected_scholar_profile_id": selected_scholar_id, + "unread_count": unread_count, + "favorites_count": favorites_count, + "latest_count": latest_count, + "new_count": latest_count, + "total_count": total_count, + "page": int(page), + "page_size": int(page_size), + "has_prev": int(offset) > 0, + "has_next": int(offset) + int(page_size) < int(total_count), + "publications": [_serialize_publication_item(item) for item in publications], + } + + +def _retry_pdf_message(*, queued: bool, resolved_pdf: bool, pdf_status: str) -> str: + if resolved_pdf: + return "Open-access PDF link already resolved." + if queued: + return "PDF lookup queued." + if pdf_status in {"queued", "running"}: + return "PDF lookup is already queued." + return "No open-access PDF link found." + + +def _favorite_message(*, is_favorite: bool) -> str: + if is_favorite: + return "Publication marked as favorite." + return "Publication removed from favorites." + + +async def _retry_publication_state( + db_session: AsyncSession, + *, + current_user: User, + scholar_profile_id: int, + publication_id: int, +): + publication = await publication_service.retry_pdf_for_user( + db_session, + user_id=current_user.id, + scholar_profile_id=scholar_profile_id, + publication_id=publication_id, + ) + if publication is None: + raise ApiException( + status_code=404, + code="publication_not_found", + message="Publication not found.", + ) + queued = False + if not publication.pdf_url: + queued = await publication_service.schedule_retry_pdf_enrichment_for_row( + db_session, + user_id=current_user.id, + request_email=current_user.email, + item=publication, + ) + hydrated = await publication_service.hydrate_pdf_enrichment_state( + db_session, + items=[publication], + ) + current = hydrated[0] if hydrated else publication + return current, queued + + +async def _favorite_publication_state( + db_session: AsyncSession, + *, + current_user: User, + scholar_profile_id: int, + publication_id: int, + is_favorite: bool, +): + updated_count = await publication_service.set_publication_favorite_for_user( + db_session, + user_id=current_user.id, + scholar_profile_id=scholar_profile_id, + publication_id=publication_id, + is_favorite=is_favorite, + ) + if updated_count <= 0: + raise ApiException( + status_code=404, + code="publication_not_found", + message="Publication not found.", + ) + publication = await publication_service.get_publication_item_for_user( + db_session, + user_id=current_user.id, + scholar_profile_id=scholar_profile_id, + publication_id=publication_id, + ) + if publication is None: + raise ApiException( + status_code=404, + code="publication_not_found", + message="Publication not found.", + ) + hydrated = await publication_service.hydrate_pdf_enrichment_state( + db_session, + items=[publication], + ) + 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 + + +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, + }, + ) + + +@router.get( + "", + response_model=PublicationsListEnvelope, +) +async def list_publications( + request: Request, + 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), + 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), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + resolved_page, resolved_limit, resolved_offset = _resolve_publications_paging( + page=page, + page_size=page_size, + limit=limit, + offset=offset, + ) + 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, + limit=resolved_limit, + offset=resolved_offset, + ) + 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, + ) + data = _publications_list_data( + mode=resolved_mode, + favorite_only=favorite_only, + selected_scholar_id=selected_scholar_id, + unread_count=unread_count, + favorites_count=favorites_count, + latest_count=latest_count, + total_count=total_count, + publications=publications, + page=resolved_page, + page_size=resolved_limit, + offset=resolved_offset, + ) + return success_payload(request, data=data) + + +@router.post( + "/mark-all-read", + response_model=MarkAllReadEnvelope, +) +async def mark_all_publications_read( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + updated_count = await publication_service.mark_all_unread_as_read_for_user( + 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, + }, + ) + return success_payload( + request, + data={ + "message": "Marked all unread publications as read.", + "updated_count": updated_count, + }, + ) + + +@router.post( + "/mark-read", + response_model=MarkSelectedReadEnvelope, +) +async def mark_selected_publications_read( + payload: MarkSelectedReadRequest, + request: Request, + 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 + } + ) + updated_count = await publication_service.mark_selected_as_read_for_user( + db_session, + user_id=current_user.id, + selections=selection_pairs, + ) + 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, + }, + ) + return success_payload( + request, + data={ + "message": "Marked selected publications as read.", + "requested_count": len(selection_pairs), + "updated_count": updated_count, + }, + ) + + +@router.post( + "/{publication_id}/retry-pdf", + response_model=RetryPublicationPdfEnvelope, +) +async def retry_publication_pdf( + payload: RetryPublicationPdfRequest, + request: Request, + publication_id: int = Path(ge=1), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + current, queued = await _retry_publication_state( + db_session, + current_user=current_user, + scholar_profile_id=payload.scholar_profile_id, + publication_id=publication_id, + ) + resolved_pdf = bool(current.pdf_url) + message = _retry_pdf_message( + queued=queued, + resolved_pdf=resolved_pdf, + pdf_status=current.pdf_status, + ) + _log_retry_pdf_result( + current_user=current_user, + scholar_profile_id=payload.scholar_profile_id, + publication_id=publication_id, + queued=queued, + resolved_pdf=resolved_pdf, + pdf_status=current.pdf_status, + doi=None, + ) + return success_payload( + request, + data={ + "message": message, + "queued": queued, + "resolved_pdf": resolved_pdf, + "publication": _serialize_publication_item(current), + }, + ) + + +@router.post( + "/{publication_id}/favorite", + response_model=TogglePublicationFavoriteEnvelope, +) +async def toggle_publication_favorite( + payload: TogglePublicationFavoriteRequest, + request: Request, + publication_id: int = Path(ge=1), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + current = await _favorite_publication_state( + db_session, + current_user=current_user, + scholar_profile_id=payload.scholar_profile_id, + publication_id=publication_id, + is_favorite=payload.is_favorite, + ) + 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), + }, + ) + return success_payload( + request, + data={ + "message": _favorite_message(is_favorite=bool(payload.is_favorite)), + "publication": _serialize_publication_item(current), + }, + ) diff --git a/build/lib/app/api/routers/runs.py b/build/lib/app/api/routers/runs.py new file mode 100644 index 0000000..4e85af4 --- /dev/null +++ b/build/lib/app/api/routers/runs.py @@ -0,0 +1,845 @@ +from __future__ import annotations + +import logging +import re +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request +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 fastapi.responses import StreamingResponse +from app.api.schemas import ( + ManualRunEnvelope, + QueueClearEnvelope, + QueueItemEnvelope, + QueueListEnvelope, + RunDetailEnvelope, + 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.runs.events import event_generator +from app.services.domains.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__) + +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, + } + + +@router.get( + "", + response_model=RunsListEnvelope, +) +async def list_runs( + request: Request, + failed_only: bool = Query(default=False), + limit: int = Query(default=100, ge=1, le=500), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + runs = await run_service.list_runs_for_user( + db_session, + user_id=current_user.id, + limit=limit, + failed_only=failed_only, + ) + 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], + "safety_state": safety_state, + }, + ) + + +@router.get( + "/{run_id}", + response_model=RunDetailEnvelope, +) +async def get_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.", + ) + 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, + }, + ) + + +@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 == RunStatus.RUNNING: + run.status = RunStatus.CANCELED + await db_session.commit() + await db_session.refresh(run) + + 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, + }, + ) + + +@router.post( + "/manual", + response_model=ManualRunEnvelope, +) +async def run_manual( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + 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, + ) + if not settings.ingestion_manual_run_allowed: + _raise_manual_runs_disabled(user_id=current_user.id, safety_state=safety_state) + + 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, + 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, + idempotency_key=idempotency_key, + safety_state=current_safety_state, + ), + ) + + + +@router.get( + "/queue/items", + response_model=QueueListEnvelope, +) +async def list_queue_items( + request: Request, + limit: int = Query(default=200, ge=1, le=500), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + items = await run_service.list_queue_items_for_user( + db_session, + user_id=current_user.id, + limit=limit, + ) + return success_payload( + request, + data={ + "queue_items": [_serialize_queue_item(item) for item in items], + }, + ) + + +@router.post( + "/queue/{queue_item_id}/retry", + response_model=QueueItemEnvelope, +) +async def retry_queue_item( + queue_item_id: int, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + try: + queue_item = await run_service.retry_queue_item_for_user( + db_session, + user_id=current_user.id, + queue_item_id=queue_item_id, + ) + except run_service.QueueTransitionError as exc: + raise ApiException( + status_code=409, + code=exc.code, + message=exc.message, + details={"current_status": exc.current_status}, + ) from exc + if queue_item is None: + raise ApiException( + status_code=404, + code="queue_item_not_found", + message="Queue item not found.", + ) + return success_payload( + request, + data=_serialize_queue_item(queue_item), + ) + + +@router.post( + "/queue/{queue_item_id}/drop", + response_model=QueueItemEnvelope, +) +async def drop_queue_item( + queue_item_id: int, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + try: + dropped = await run_service.drop_queue_item_for_user( + db_session, + user_id=current_user.id, + queue_item_id=queue_item_id, + ) + except run_service.QueueTransitionError as exc: + raise ApiException( + status_code=409, + code=exc.code, + message=exc.message, + details={"current_status": exc.current_status}, + ) from exc + if dropped is None: + raise ApiException( + status_code=404, + code="queue_item_not_found", + message="Queue item not found.", + ) + return success_payload( + request, + data=_serialize_queue_item(dropped), + ) + + +@router.delete( + "/queue/{queue_item_id}", + response_model=QueueClearEnvelope, +) +async def clear_queue_item( + queue_item_id: int, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + try: + deleted = await run_service.clear_queue_item_for_user( + db_session, + user_id=current_user.id, + queue_item_id=queue_item_id, + ) + except run_service.QueueTransitionError as exc: + raise ApiException( + status_code=409, + code=exc.code, + message=exc.message, + details={"current_status": exc.current_status}, + ) from exc + if deleted is None: + raise ApiException( + status_code=404, + code="queue_item_not_found", + message="Queue item not found.", + ) + return success_payload( + request, + data={ + "queue_item_id": deleted.queue_item_id, + "previous_status": deleted.previous_status, + "status": "cleared", + "message": "Queue item cleared.", + }, + ) + + +@router.get("/{run_id}/stream") +async def stream_run_events( + run_id: int, + 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.", + ) + return StreamingResponse( + event_generator(run_id), + media_type="text/event-stream" + ) diff --git a/build/lib/app/api/routers/scholars.py b/build/lib/app/api/routers/scholars.py new file mode 100644 index 0000000..f4be187 --- /dev/null +++ b/build/lib/app/api/routers/scholars.py @@ -0,0 +1,557 @@ +from __future__ import annotations + +import asyncio +import logging + +from fastapi import APIRouter, Depends, File, Query, Request, UploadFile +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.runtime_deps import get_scholar_source +from app.api.schemas import ( + DataExportEnvelope, + DataImportEnvelope, + DataImportRequest, + MessageEnvelope, + ScholarCreateRequest, + ScholarEnvelope, + ScholarImageUrlUpdateRequest, + ScholarSearchEnvelope, + ScholarsListEnvelope, +) +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.settings import settings + +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, +) +async def list_scholars( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + scholars = await scholar_service.list_scholars_for_user( + db_session, + user_id=current_user.id, + ) + return success_payload( + request, + data={ + "scholars": [_serialize_scholar(profile) for profile in scholars], + }, + ) + + +@router.get( + "/export", + response_model=DataExportEnvelope, +) +async def export_scholars_and_publications( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + data = await import_export_service.export_user_data( + db_session, + user_id=current_user.id, + ) + return success_payload(request, data=data) + + +@router.post( + "/import", + response_model=DataImportEnvelope, +) +async def import_scholars_and_publications( + payload: DataImportRequest, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + if ( + payload.schema_version is not None + and int(payload.schema_version) != import_export_service.EXPORT_SCHEMA_VERSION + ): + raise ApiException( + status_code=400, + code="invalid_import_schema_version", + message=( + "Import schema version is not supported. " + f"Expected {import_export_service.EXPORT_SCHEMA_VERSION}." + ), + ) + try: + result = await import_export_service.import_user_data( + db_session, + user_id=current_user.id, + scholars=[item.model_dump() for item in payload.scholars], + publications=[item.model_dump() for item in payload.publications], + ) + except import_export_service.ImportExportError as exc: + raise ApiException( + status_code=400, + 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, + }, + ) + return success_payload(request, data=result) + + +@router.post( + "", + response_model=ScholarEnvelope, + status_code=201, +) +async def create_scholar( + payload: ScholarCreateRequest, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + source: ScholarSource = Depends(get_scholar_source), + current_user: User = Depends(get_api_current_user), +): + try: + created = await scholar_service.create_scholar_for_user( + db_session, + user_id=current_user.id, + scholar_id=payload.scholar_id, + display_name="", + profile_image_url=payload.profile_image_url, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=400, + 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( + db_session, + profile=created, + source=source, + user_id=current_user.id, + ) + + return success_payload( + request, + data=_serialize_scholar(created), + ) + + +@router.get( + "/search", + response_model=ScholarSearchEnvelope, +) +async def search_scholars( + request: Request, + query: str = Query(..., min_length=2, max_length=120), + limit: int = Query(10, ge=1, le=25), + db_session: AsyncSession = Depends(get_db_session), + source: ScholarSource = Depends(get_scholar_source), + current_user: User = Depends(get_api_current_user), +): + try: + parsed = await scholar_service.search_author_candidates( + source=source, + db_session=db_session, + query=query, + limit=limit, + **_search_kwargs(), + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=400, + code="invalid_scholar_search", + message=str(exc), + ) from exc + + 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, + }, + ) + return success_payload( + request, + data=_search_response_data(query, parsed), + ) + + +@router.patch( + "/{scholar_profile_id}/toggle", + response_model=ScholarEnvelope, +) +async def toggle_scholar( + scholar_profile_id: int, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=current_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.", + ) + updated = await scholar_service.toggle_scholar_enabled(db_session, profile=profile) + 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, + }, + ) + return success_payload( + request, + data=_serialize_scholar(updated), + ) + + +@router.delete( + "/{scholar_profile_id}", + response_model=MessageEnvelope, +) +async def delete_scholar( + scholar_profile_id: int, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=current_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.", + ) + await scholar_service.delete_scholar( + db_session, + 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, + }, + ) + return success_payload( + request, + data={"message": "Scholar deleted."}, + ) + + +@router.put( + "/{scholar_profile_id}/image/url", + response_model=ScholarEnvelope, +) +async def update_scholar_image_url( + scholar_profile_id: int, + payload: ScholarImageUrlUpdateRequest, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=current_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.", + ) + + try: + updated = await scholar_service.set_profile_image_override_url( + db_session, + profile=profile, + image_url=payload.image_url, + upload_dir=settings.scholar_image_upload_dir, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=400, + code="invalid_scholar_image", + 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, + }, + ) + return success_payload( + request, + data=_serialize_scholar(updated), + ) + + +@router.post( + "/{scholar_profile_id}/image/upload", + response_model=ScholarEnvelope, +) +async def upload_scholar_image( + scholar_profile_id: int, + request: Request, + image: UploadFile = File(...), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + 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) + try: + updated = await scholar_service.set_profile_image_upload( + db_session, + profile=profile, + content_type=image.content_type, + image_bytes=image_bytes, + upload_dir=settings.scholar_image_upload_dir, + max_upload_bytes=settings.scholar_image_upload_max_bytes, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=400, + code="invalid_scholar_image", + message=str(exc), + ) from exc + + image_size = len(image_bytes) + 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, + }, + ) + return success_payload( + request, + data=_serialize_scholar(updated), + ) + + +@router.delete( + "/{scholar_profile_id}/image", + response_model=ScholarEnvelope, +) +async def clear_scholar_image_customization( + scholar_profile_id: int, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + profile = await scholar_service.get_user_scholar_by_id( + db_session, + user_id=current_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.", + ) + + updated = await scholar_service.clear_profile_image_customization( + db_session, + 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, + }, + ) + return success_payload( + request, + data=_serialize_scholar(updated), + ) + diff --git a/build/lib/app/api/routers/settings.py b/build/lib/app/api/routers/settings.py new file mode 100644 index 0000000..5a2ef91 --- /dev/null +++ b/build/lib/app/api/routers/settings.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, Request +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.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.settings import settings as settings_module + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/settings", tags=["api-settings"]) + + +def _serialize_settings(user_settings) -> dict[str, object]: + min_run_interval_minutes, min_request_delay_seconds = _minimum_policy() + return { + "auto_run_enabled": bool(user_settings.auto_run_enabled) and settings_module.ingestion_automation_allowed, + "run_interval_minutes": int(user_settings.run_interval_minutes), + "request_delay_seconds": int(user_settings.request_delay_seconds), + "nav_visible_pages": list(user_settings.nav_visible_pages or []), + "policy": { + "min_run_interval_minutes": min_run_interval_minutes, + "min_request_delay_seconds": min_request_delay_seconds, + "automation_allowed": bool(settings_module.ingestion_automation_allowed), + "manual_run_allowed": bool(settings_module.ingestion_manual_run_allowed), + "blocked_failure_threshold": max(1, int(settings_module.ingestion_alert_blocked_failure_threshold)), + "network_failure_threshold": max(1, int(settings_module.ingestion_alert_network_failure_threshold)), + "cooldown_blocked_seconds": max(60, int(settings_module.ingestion_safety_cooldown_blocked_seconds)), + "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, + } + + +def _minimum_policy() -> tuple[int, int]: + min_run_interval_minutes = user_settings_service.resolve_run_interval_minimum( + settings_module.ingestion_min_run_interval_minutes + ) + min_request_delay_seconds = user_settings_service.resolve_request_delay_minimum( + settings_module.ingestion_min_request_delay_seconds + ) + return min_run_interval_minutes, min_request_delay_seconds + + +def _parse_settings_payload(payload: SettingsUpdateRequest, user_settings) -> tuple[int, int, list[str]]: + min_run_interval_minutes, min_request_delay_seconds = _minimum_policy() + parsed_interval = user_settings_service.parse_run_interval_minutes( + str(payload.run_interval_minutes), + minimum=min_run_interval_minutes, + ) + parsed_delay = user_settings_service.parse_request_delay_seconds( + str(payload.request_delay_seconds), + minimum=min_request_delay_seconds, + ) + parsed_nav_visible_pages = user_settings_service.parse_nav_visible_pages( + payload.nav_visible_pages + if payload.nav_visible_pages is not None + else list(user_settings.nav_visible_pages or user_settings_service.DEFAULT_NAV_VISIBLE_PAGES) + ) + return parsed_interval, parsed_delay, parsed_nav_visible_pages + + +async def _clear_expired_cooldown_with_log( + db_session: AsyncSession, + *, + user_id: int, + user_settings, +) -> None: + previous_safety_state = run_safety_service.get_safety_event_context(user_settings) + if not run_safety_service.clear_expired_cooldown(user_settings): + return + await db_session.commit() + await db_session.refresh(user_settings) + 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, + }, + ) + + +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, + "openalex_api_key": "SET" if updated.openalex_api_key else "UNSET", + }, + ) + + +@router.get( + "", + response_model=SettingsEnvelope, +) +async def get_settings( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + user_settings = await user_settings_service.get_or_create_settings( + db_session, + user_id=current_user.id, + ) + await _clear_expired_cooldown_with_log( + db_session, + user_id=current_user.id, + user_settings=user_settings, + ) + return success_payload( + request, + data=_serialize_settings(user_settings), + ) + + +@router.put( + "", + response_model=SettingsEnvelope, +) +async def update_settings( + payload: SettingsUpdateRequest, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + user_settings = await user_settings_service.get_or_create_settings( + db_session, + user_id=current_user.id, + ) + + try: + parsed_interval, parsed_delay, parsed_nav_visible_pages = _parse_settings_payload( + payload, + user_settings, + ) + except user_settings_service.UserSettingsServiceError as exc: + raise ApiException( + status_code=400, + code="invalid_settings", + message=str(exc), + ) from exc + + updated = await user_settings_service.update_settings( + db_session, + settings=user_settings, + auto_run_enabled=_effective_auto_run_enabled(payload), + 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) + return success_payload( + request, + data=_serialize_settings(updated), + ) diff --git a/build/lib/app/api/runtime_deps.py b/build/lib/app/api/runtime_deps.py new file mode 100644 index 0000000..3a692a3 --- /dev/null +++ b/build/lib/app/api/runtime_deps.py @@ -0,0 +1,16 @@ +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 + + +def get_scholar_source() -> scholar_source_service.ScholarSource: + return scholar_source_service.LiveScholarSource() + + +def get_ingestion_service( + source: scholar_source_service.ScholarSource = Depends(get_scholar_source), +) -> ingestion_service.ScholarIngestionService: + return ingestion_service.ScholarIngestionService(source=source) diff --git a/build/lib/app/api/schemas.py b/build/lib/app/api/schemas.py new file mode 100644 index 0000000..bac3bb9 --- /dev/null +++ b/build/lib/app/api/schemas.py @@ -0,0 +1,884 @@ +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 + 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 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 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 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") + + +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 + 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/build/lib/app/auth/__init__.py b/build/lib/app/auth/__init__.py new file mode 100644 index 0000000..79644a4 --- /dev/null +++ b/build/lib/app/auth/__init__.py @@ -0,0 +1,2 @@ +"""Authentication package for scholarr.""" + diff --git a/build/lib/app/auth/deps.py b/build/lib/app/auth/deps.py new file mode 100644 index 0000000..50424ae --- /dev/null +++ b/build/lib/app/auth/deps.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from functools import lru_cache + +from app.auth.rate_limit import SlidingWindowRateLimiter +from app.auth.security import PasswordService +from app.auth.service import AuthService +from app.settings import settings + + +@lru_cache +def get_password_service() -> PasswordService: + return PasswordService() + + +@lru_cache +def get_auth_service() -> AuthService: + return AuthService(password_service=get_password_service()) + + +@lru_cache +def get_login_rate_limiter() -> SlidingWindowRateLimiter: + return SlidingWindowRateLimiter( + max_attempts=settings.login_rate_limit_attempts, + window_seconds=settings.login_rate_limit_window_seconds, + ) + diff --git a/build/lib/app/auth/rate_limit.py b/build/lib/app/auth/rate_limit.py new file mode 100644 index 0000000..c3f5e8a --- /dev/null +++ b/build/lib/app/auth/rate_limit.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass +from math import ceil +from threading import Lock +from time import monotonic + + +@dataclass(frozen=True) +class RateLimitDecision: + allowed: bool + retry_after_seconds: int = 0 + + +class SlidingWindowRateLimiter: + def __init__( + self, + *, + max_attempts: int, + window_seconds: int, + now: Callable[[], float] = monotonic, + ) -> None: + self._max_attempts = max_attempts + self._window_seconds = window_seconds + self._now = now + self._attempts: dict[str, deque[float]] = {} + self._lock = Lock() + + def check(self, key: str) -> RateLimitDecision: + with self._lock: + now_value = self._now() + attempts = self._attempts.get(key) + if attempts is None: + return RateLimitDecision(allowed=True) + self._trim_expired(attempts, now_value) + if not attempts: + self._attempts.pop(key, None) + return RateLimitDecision(allowed=True) + if len(attempts) >= self._max_attempts: + retry_after = self._window_seconds - (now_value - attempts[0]) + return RateLimitDecision( + allowed=False, + retry_after_seconds=max(1, ceil(retry_after)), + ) + return RateLimitDecision(allowed=True) + + def record_failure(self, key: str) -> None: + with self._lock: + now_value = self._now() + attempts = self._attempts.get(key) + if attempts is None: + attempts = deque() + self._attempts[key] = attempts + self._trim_expired(attempts, now_value) + attempts.append(now_value) + + def reset(self, key: str) -> None: + with self._lock: + self._attempts.pop(key, None) + + def clear_all(self) -> None: + with self._lock: + self._attempts.clear() + + def _trim_expired(self, attempts: deque[float], now_value: float) -> None: + while attempts and now_value - attempts[0] >= self._window_seconds: + attempts.popleft() diff --git a/build/lib/app/auth/runtime.py b/build/lib/app/auth/runtime.py new file mode 100644 index 0000000..777e799 --- /dev/null +++ b/build/lib/app/auth/runtime.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import logging + +from fastapi import Request +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.security.csrf import CSRF_SESSION_KEY +from app.services.domains.users import application as user_service + +logger = logging.getLogger(__name__) + + +def invalidate_session(request: Request) -> None: + clear_session_user(request) + request.session.pop(CSRF_SESSION_KEY, None) + + +async def get_authenticated_user( + request: Request, + db_session: AsyncSession, +) -> User | None: + session_user = get_session_user(request) + if session_user is None: + return None + + 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, + }, + ) + invalidate_session(request) + return None + + if user.email != session_user.email or user.is_admin != session_user.is_admin: + set_session_user( + request, + user_id=user.id, + email=user.email, + is_admin=user.is_admin, + ) + + return user + + +def login_rate_limit_key(request: Request, email: str) -> str: + client_host = request.client.host if request.client is not None else "unknown" + normalized_email = email.strip().lower() + return f"{client_host}:{normalized_email or ''}" diff --git a/build/lib/app/auth/security.py b/build/lib/app/auth/security.py new file mode 100644 index 0000000..f89b122 --- /dev/null +++ b/build/lib/app/auth/security.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError + + +class PasswordService: + def __init__(self, hasher: PasswordHasher | None = None) -> None: + self._hasher = hasher or PasswordHasher() + + def hash_password(self, password: str) -> str: + return self._hasher.hash(password) + + def verify_password(self, password_hash: str, password: str) -> bool: + try: + return bool(self._hasher.verify(password_hash, password)) + except (InvalidHashError, VerificationError): + return False + diff --git a/build/lib/app/auth/service.py b/build/lib/app/auth/service.py new file mode 100644 index 0000000..9772358 --- /dev/null +++ b/build/lib/app/auth/service.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.security import PasswordService +from app.db.models import User + + +class AuthService: + def __init__(self, password_service: PasswordService) -> None: + self._password_service = password_service + + async def authenticate_user( + self, + db_session: AsyncSession, + *, + email: str, + password: str, + ) -> User | None: + 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) + ) + user = result.scalar_one_or_none() + if user is None or not user.is_active: + return None + if not self._password_service.verify_password(user.password_hash, password): + return None + return user + + def hash_password(self, password: str) -> str: + return self._password_service.hash_password(password) + + def verify_password(self, *, password_hash: str, password: str) -> bool: + return self._password_service.verify_password(password_hash, password) diff --git a/build/lib/app/auth/session.py b/build/lib/app/auth/session.py new file mode 100644 index 0000000..8c83197 --- /dev/null +++ b/build/lib/app/auth/session.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +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" + + +@dataclass(frozen=True) +class SessionUser: + id: int + email: str + is_admin: bool + + +def get_session_user(request: Request) -> SessionUser | None: + user_id = request.session.get(SESSION_USER_ID_KEY) + email = request.session.get(SESSION_USER_EMAIL_KEY) + is_admin = request.session.get(SESSION_USER_IS_ADMIN_KEY) + if user_id is None or email is None or is_admin is None: + return None + try: + parsed_user_id = int(user_id) + except (TypeError, ValueError): + return None + if not isinstance(email, str): + return None + return SessionUser(id=parsed_user_id, email=email, is_admin=bool(is_admin)) + + +def set_session_user( + request: Request, + *, + user_id: int, + email: str, + is_admin: bool, +) -> None: + request.session[SESSION_USER_ID_KEY] = int(user_id) + request.session[SESSION_USER_EMAIL_KEY] = email + request.session[SESSION_USER_IS_ADMIN_KEY] = bool(is_admin) + + +def clear_session_user(request: Request) -> None: + request.session.pop(SESSION_USER_ID_KEY, None) + request.session.pop(SESSION_USER_EMAIL_KEY, None) + request.session.pop(SESSION_USER_IS_ADMIN_KEY, None) diff --git a/build/lib/app/db/__init__.py b/build/lib/app/db/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/build/lib/app/db/__init__.py @@ -0,0 +1 @@ + diff --git a/build/lib/app/db/base.py b/build/lib/app/db/base.py new file mode 100644 index 0000000..6fa2049 --- /dev/null +++ b/build/lib/app/db/base.py @@ -0,0 +1,27 @@ +from datetime import datetime, timezone + +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", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION) + + +@event.listens_for(Base, "before_update", propagate=True) +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) + + +metadata = Base.metadata diff --git a/build/lib/app/db/models.py b/build/lib/app/db/models.py new file mode 100644 index 0000000..1a8ad8a --- /dev/null +++ b/build/lib/app/db/models.py @@ -0,0 +1,546 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum + +from sqlalchemy import ( + Boolean, + CheckConstraint, + DateTime, + Enum, + Float, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, + func, + text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class RunTriggerType(StrEnum): + MANUAL = "manual" + SCHEDULED = "scheduled" + + +class RunStatus(StrEnum): + RUNNING = "running" + SUCCESS = "success" + PARTIAL_FAILURE = "partial_failure" + FAILED = "failed" + CANCELED = "canceled" + + +class QueueItemStatus(StrEnum): + QUEUED = "queued" + RETRYING = "retrying" + DROPPED = "dropped" + + +RUN_STATUS_DB_ENUM = Enum( + RunStatus, + name="run_status", + values_callable=lambda members: [member.value for member in members], +) +RUN_TRIGGER_TYPE_DB_ENUM = Enum( + RunTriggerType, + name="run_trigger_type", + values_callable=lambda members: [member.value for member in members], +) + + +class User(Base): + __tablename__ = "users" + + 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() + ) + + +class UserSetting(Base): + __tablename__ = "user_settings" + __table_args__ = ( + CheckConstraint( + "run_interval_minutes >= 15", + name="run_interval_minutes_min_15", + ), + CheckConstraint( + "request_delay_seconds >= 2", + name="request_delay_seconds_min_2", + ), + ) + + 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, + server_default=text( + '\'["dashboard","scholars","publications","settings","style-guide","runs","users"]\'::jsonb' + ), + ) + scrape_safety_state: Mapped[dict] = mapped_column( + JSONB, + nullable=False, + server_default=text("'{}'::jsonb"), + ) + scrape_cooldown_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + scrape_cooldown_reason: Mapped[str | None] = mapped_column(String(64)) + + 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): + __tablename__ = "scholar_profiles" + __table_args__ = ( + UniqueConstraint("user_id", "scholar_id", name="uq_scholar_profiles_user_scholar"), + Index("ix_scholar_profiles_user_enabled", "user_id", "is_enabled"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + 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) + profile_image_override_url: Mapped[str | None] = mapped_column(Text) + 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") + ) + 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() + ) + + +class CrawlRun(Base): + __tablename__ = "crawl_runs" + __table_args__ = ( + Index("ix_crawl_runs_user_start", "user_id", "start_dt"), + 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" + ), + ), + ) + + 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() + ) + 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") + ) + 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() + ) + + +class Publication(Base): + __tablename__ = "publications" + __table_args__ = ( + UniqueConstraint("fingerprint_sha256", name="uq_publications_fingerprint"), + Index( + "uq_publications_cluster_id_not_null", + "cluster_id", + unique=True, + postgresql_where=text("cluster_id IS NOT NULL"), + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + cluster_id: Mapped[str | None] = mapped_column(String(64)) + fingerprint_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + 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") + ) + author_text: Mapped[str | None] = mapped_column(Text) + venue_text: Mapped[str | None] = mapped_column(Text) + pub_url: Mapped[str | None] = mapped_column(Text) + pdf_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 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", + ), + ) + + 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): + __tablename__ = "publication_pdf_jobs" + __table_args__ = ( + CheckConstraint( + "status IN ('queued', 'running', 'resolved', 'failed')", + name="publication_pdf_jobs_status_valid", + ), + Index("ix_publication_pdf_jobs_status", "status"), + Index("ix_publication_pdf_jobs_updated_at", "updated_at"), + Index("ix_publication_pdf_jobs_queued_at", "queued_at"), + ) + + publication_id: Mapped[int] = mapped_column( + ForeignKey("publications.id", ondelete="CASCADE"), + primary_key=True, + ) + status: Mapped[str] = mapped_column( + String(16), + nullable=False, + server_default=text("'queued'"), + ) + attempt_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + server_default=text("0"), + ) + queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_failure_reason: Mapped[str | None] = mapped_column(String(64)) + last_failure_detail: Mapped[str | None] = mapped_column(Text) + last_source: Mapped[str | None] = mapped_column(String(32)) + 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() + ) + + +class PublicationPdfJobEvent(Base): + __tablename__ = "publication_pdf_job_events" + __table_args__ = ( + Index("ix_publication_pdf_job_events_publication_created", "publication_id", "created_at"), + Index("ix_publication_pdf_job_events_created_at", "created_at"), + Index("ix_publication_pdf_job_events_event_type", "event_type"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + publication_id: Mapped[int] = mapped_column( + ForeignKey("publications.id", ondelete="CASCADE"), + nullable=False, + ) + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), + ) + event_type: Mapped[str] = mapped_column(String(32), nullable=False) + status: Mapped[str | None] = mapped_column(String(16)) + 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() + ) + + +class ScholarPublication(Base): + __tablename__ = "scholar_publications" + __table_args__ = ( + Index("ix_scholar_publications_is_read", "is_read"), + Index("ix_scholar_publications_is_favorite", "is_favorite"), + ) + + scholar_profile_id: Mapped[int] = mapped_column( + ForeignKey("scholar_profiles.id", ondelete="CASCADE"), + primary_key=True, + ) + publication_id: Mapped[int] = mapped_column( + 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() + ) + + +class IngestionQueueItem(Base): + __tablename__ = "ingestion_queue_items" + __table_args__ = ( + UniqueConstraint( + "user_id", + "scholar_profile_id", + name="uq_ingestion_queue_user_scholar", + ), + CheckConstraint( + "status IN ('queued', 'retrying', 'dropped')", + name="ingestion_queue_status_valid", + ), + Index("ix_ingestion_queue_next_attempt", "next_attempt_dt"), + Index("ix_ingestion_queue_status_next_attempt", "status", "next_attempt_dt"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + scholar_profile_id: Mapped[int] = mapped_column( + ForeignKey("scholar_profiles.id", ondelete="CASCADE"), + nullable=False, + ) + resume_cstart: Mapped[int] = mapped_column( + Integer, + nullable=False, + server_default=text("0"), + ) + reason: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[str] = mapped_column( + String(16), + nullable=False, + server_default=text("'queued'"), + ) + attempt_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + server_default=text("0"), + ) + next_attempt_dt: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + last_run_id: Mapped[int | None] = mapped_column( + ForeignKey("crawl_runs.id", ondelete="SET NULL"), + ) + 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() + ) + + +class DataRepairJob(Base): + __tablename__ = "data_repair_jobs" + __table_args__ = ( + CheckConstraint( + "status IN ('planned', 'running', 'completed', 'failed')", + name="data_repair_jobs_status_valid", + ), + Index("ix_data_repair_jobs_created_at", "created_at"), + Index("ix_data_repair_jobs_job_name_created_at", "job_name", "created_at"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + job_name: Mapped[str] = mapped_column(String(64), nullable=False) + requested_by: Mapped[str | None] = mapped_column(String(255)) + scope: Mapped[dict] = mapped_column( + JSONB, + nullable=False, + server_default=text("'{}'::jsonb"), + ) + dry_run: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + server_default=text("true"), + ) + status: Mapped[str] = mapped_column( + String(16), + nullable=False, + server_default=text("'planned'"), + ) + summary: Mapped[dict] = mapped_column( + JSONB, + nullable=False, + server_default=text("'{}'::jsonb"), + ) + 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() + ) + + +class AuthorSearchRuntimeState(Base): + __tablename__ = "author_search_runtime_state" + + state_key: Mapped[str] = mapped_column(String(64), primary_key=True) + last_live_request_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + cooldown_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + consecutive_blocked_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + server_default=text("0"), + ) + cooldown_rejection_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + server_default=text("0"), + ) + cooldown_alert_emitted: 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 AuthorSearchCacheEntry(Base): + __tablename__ = "author_search_cache_entries" + __table_args__ = ( + Index("ix_author_search_cache_expires_at", "expires_at"), + Index("ix_author_search_cache_cached_at", "cached_at"), + ) + + query_key: Mapped[str] = mapped_column(String(256), 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() + ) diff --git a/build/lib/app/db/session.py b/build/lib/app/db/session.py new file mode 100644 index 0000000..c05266e --- /dev/null +++ b/build/lib/app/db/session.py @@ -0,0 +1,99 @@ +from collections.abc import AsyncIterator +import logging +import os + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.pool import NullPool + +from app.settings import settings + +logger = logging.getLogger(__name__) + +_engine: AsyncEngine | None = None +_session_factory: async_sessionmaker[AsyncSession] | None = None + + +def _normalized_pool_mode(raw_mode: str) -> str: + mode = (raw_mode or "").strip().lower() + if mode == "auto": + if os.getenv("PYTEST_CURRENT_TEST"): + return "null" + app_env = (os.getenv("APP_ENV") or "").strip().lower() + if app_env in {"test", "development", "dev", "local"}: + return "null" + return "queue" + if mode in {"null", "queue"}: + return mode + logger.warning( + "db.invalid_pool_mode_fallback", + extra={ + "event": "db.invalid_pool_mode_fallback", + "database_pool_mode": raw_mode, + "fallback_mode": "queue", + }, + ) + return "queue" + + +def get_engine() -> AsyncEngine: + global _engine + if _engine is None: + pool_mode = _normalized_pool_mode(settings.database_pool_mode) + engine_kwargs: dict[str, object] = { + "pool_pre_ping": True, + } + if pool_mode == "null": + engine_kwargs["poolclass"] = NullPool + else: + engine_kwargs["pool_size"] = max(1, int(settings.database_pool_size)) + engine_kwargs["max_overflow"] = max(0, int(settings.database_pool_max_overflow)) + engine_kwargs["pool_timeout"] = max(1, int(settings.database_pool_timeout_seconds)) + + _engine = create_async_engine(settings.database_url, **engine_kwargs) + logger.info( + "db.engine_initialized", + extra={ + "event": "db.engine_initialized", + "pool_mode": pool_mode, + }, + ) + return _engine + + +def get_session_factory() -> async_sessionmaker[AsyncSession]: + global _session_factory + if _session_factory is None: + _session_factory = async_sessionmaker(get_engine(), expire_on_commit=False) + return _session_factory + + +async def get_db_session() -> AsyncIterator[AsyncSession]: + session_factory = get_session_factory() + async with session_factory() as session: + yield session + + +async def check_database() -> bool: + engine = get_engine() + try: + async with engine.connect() as conn: + result = await conn.execute(text("SELECT 1")) + return result.scalar_one() == 1 + except Exception: + logger.exception("db.healthcheck_failed", extra={"event": "db.healthcheck_failed"}) + return False + + +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"}) + _engine = None + _session_factory = None diff --git a/build/lib/app/http/__init__.py b/build/lib/app/http/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/build/lib/app/http/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/build/lib/app/http/middleware.py b/build/lib/app/http/middleware.py new file mode 100644 index 0000000..ebe72f8 --- /dev/null +++ b/build/lib/app/http/middleware.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from secrets import token_urlsafe +import logging +import time + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from app.logging_context import set_request_id + +REQUEST_ID_HEADER = "X-Request-ID" +DEFAULT_PERMISSIONS_POLICY = ( + "accelerometer=(), autoplay=(), camera=(), display-capture=(), " + "geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()" +) +DEFAULT_CSP_POLICY = ( + "default-src 'self'; " + "base-uri 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'; " + "img-src 'self' data: https:; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "font-src 'self' data:; " + "connect-src 'self'; " + "object-src 'none'" +) +DEFAULT_CSP_DOCS_POLICY = ( + "default-src 'self'; " + "img-src 'self' data: https:; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "font-src 'self' data:; " + "connect-src 'self' https:; " + "object-src 'none'; " + "frame-ancestors 'none'" +) + +logger = logging.getLogger(__name__) + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + def __init__( + self, + app, + *, + log_requests: bool = True, + skip_paths: tuple[str, ...] = (), + ) -> None: + super().__init__(app) + self._log_requests = log_requests + self._skip_paths = tuple(path for path in skip_paths if path) + + async def dispatch(self, request: Request, call_next) -> Response: + request_id = request.headers.get(REQUEST_ID_HEADER) or token_urlsafe(12) + request.state.request_id = request_id + set_request_id(request_id) + + start = time.perf_counter() + should_log = self._log_requests and not self._is_skipped_path(request.url.path) + if should_log: + logger.debug( + "request.started", + extra={ + "event": "request.started", + "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( + "request.failed", + extra={ + "event": "request.failed", + "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( + "request.completed", + extra={ + "event": "request.completed", + "method": request.method, + "path": request.url.path, + "status_code": response.status_code, + "duration_ms": duration_ms, + }, + ) + return response + finally: + set_request_id(None) + + def _is_skipped_path(self, path: str) -> bool: + return any(path.startswith(prefix) for prefix in self._skip_paths) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + def __init__( + self, + app, + *, + enabled: bool = True, + x_content_type_options: str = "nosniff", + x_frame_options: str = "DENY", + referrer_policy: str = "strict-origin-when-cross-origin", + permissions_policy: str = DEFAULT_PERMISSIONS_POLICY, + cross_origin_opener_policy: str = "same-origin", + cross_origin_resource_policy: str = "same-origin", + content_security_policy_enabled: bool = True, + content_security_policy: str = DEFAULT_CSP_POLICY, + content_security_policy_docs: str = DEFAULT_CSP_DOCS_POLICY, + content_security_policy_report_only: bool = False, + strict_transport_security_enabled: bool = False, + strict_transport_security_max_age: int = 31_536_000, + strict_transport_security_include_subdomains: bool = True, + strict_transport_security_preload: bool = False, + ) -> None: + super().__init__(app) + self._enabled = enabled + self._x_content_type_options = x_content_type_options.strip() + self._x_frame_options = x_frame_options.strip() + self._referrer_policy = referrer_policy.strip() + self._permissions_policy = permissions_policy.strip() + self._cross_origin_opener_policy = cross_origin_opener_policy.strip() + self._cross_origin_resource_policy = cross_origin_resource_policy.strip() + self._csp_enabled = content_security_policy_enabled + self._csp_policy = content_security_policy.strip() + self._csp_docs_policy = content_security_policy_docs.strip() + self._csp_report_only = content_security_policy_report_only + self._hsts_enabled = strict_transport_security_enabled + self._hsts_max_age = max(0, strict_transport_security_max_age) + self._hsts_include_subdomains = strict_transport_security_include_subdomains + self._hsts_preload = strict_transport_security_preload + + async def dispatch(self, request: Request, call_next) -> Response: + response = await call_next(request) + if not self._enabled: + return response + + if self._x_content_type_options: + response.headers.setdefault("X-Content-Type-Options", self._x_content_type_options) + if self._x_frame_options: + response.headers.setdefault("X-Frame-Options", self._x_frame_options) + if self._referrer_policy: + response.headers.setdefault("Referrer-Policy", self._referrer_policy) + if self._permissions_policy: + response.headers.setdefault("Permissions-Policy", self._permissions_policy) + if self._cross_origin_opener_policy: + response.headers.setdefault( + "Cross-Origin-Opener-Policy", + self._cross_origin_opener_policy, + ) + if self._cross_origin_resource_policy: + response.headers.setdefault( + "Cross-Origin-Resource-Policy", + self._cross_origin_resource_policy, + ) + + 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" + ) + response.headers.setdefault(csp_header, csp_policy) + + hsts = self._strict_transport_security_value() + if hsts: + response.headers.setdefault("Strict-Transport-Security", hsts) + + return response + + def _csp_policy_for_path(self, path: str) -> str: + if path.startswith("/docs") or path.startswith("/redoc"): + return self._csp_docs_policy or self._csp_policy + return self._csp_policy + + def _strict_transport_security_value(self) -> str: + if not self._hsts_enabled: + return "" + + directives = [f"max-age={self._hsts_max_age}"] + if self._hsts_include_subdomains: + directives.append("includeSubDomains") + if self._hsts_preload: + directives.append("preload") + return "; ".join(directives) + + +def parse_skip_paths(raw_value: str) -> tuple[str, ...]: + parts = [part.strip() for part in raw_value.split(",")] + return tuple(part for part in parts if part) diff --git a/build/lib/app/logging_config.py b/build/lib/app/logging_config.py new file mode 100644 index 0000000..22907be --- /dev/null +++ b/build/lib/app/logging_config.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import json +import logging +import sys +from typing import Any + +from app.logging_context import get_request_id + +DEFAULT_REDACT_FIELDS = { + "authorization", + "cookie", + "csrf_token", + "new_password", + "password", + "password_hash", + "session", + "session_secret_key", +} + +_BASE_RECORD = logging.makeLogRecord({}) +_STANDARD_RECORD_FIELDS = set(_BASE_RECORD.__dict__.keys()) | {"message", "asctime"} +_NOISY_RECORD_FIELDS = {"color_message"} + + +def parse_redact_fields(raw: str | None) -> set[str]: + fields = {field.strip().lower() for field in (raw or "").split(",") if field.strip()} + return DEFAULT_REDACT_FIELDS | fields + + +def configure_logging( + *, + level: str, + log_format: str, + redact_fields: set[str], + include_uvicorn_access: bool, +) -> None: + normalized_level = _normalize_level(level) + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.setLevel(normalized_level) + + handler = logging.StreamHandler(stream=sys.stdout) + handler.setLevel(normalized_level) + handler.addFilter(RequestContextFilter()) + + normalized_format = log_format.strip().lower() + if normalized_format == "json": + handler.setFormatter(JsonLogFormatter(redact_fields=redact_fields)) + else: + handler.setFormatter(ConsoleLogFormatter(redact_fields=redact_fields)) + + root_logger.addHandler(handler) + + # Route server/framework logs through our single root handler. + for logger_name in ("uvicorn", "uvicorn.error"): + framework_logger = logging.getLogger(logger_name) + framework_logger.handlers.clear() + framework_logger.propagate = True + framework_logger.setLevel(normalized_level) + + access_logger = logging.getLogger("uvicorn.access") + access_logger.handlers.clear() + access_logger.propagate = True + access_logger.setLevel(normalized_level if include_uvicorn_access else logging.WARNING) + + +class RequestContextFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + if not getattr(record, "request_id", None): + request_id = get_request_id() + if request_id: + record.request_id = request_id + return True + + +class JsonLogFormatter(logging.Formatter): + def __init__(self, *, redact_fields: set[str]) -> None: + super().__init__() + self._redact_fields = {field.lower() for field in redact_fields} + + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "timestamp": _format_timestamp(record.created), + "level": record.levelname.lower(), + "logger": record.name, + "event": getattr(record, "event", record.getMessage()), + } + request_id = getattr(record, "request_id", None) + if request_id: + payload["request_id"] = request_id + payload.update(self._redact_mapping(_extra_fields(record))) + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=True, default=str) + + def _redact_mapping(self, value: dict[str, Any]) -> dict[str, Any]: + return {key: self._redact_value(key, item) for key, item in value.items()} + + def _redact_value(self, key: str, value: Any) -> Any: + 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()} + if isinstance(value, (list, tuple)): + return [self._redact_value(key, item) for item in value] + return value + + +class ConsoleLogFormatter(logging.Formatter): + def __init__(self, *, redact_fields: set[str]) -> None: + super().__init__() + self._json_formatter = JsonLogFormatter(redact_fields=redact_fields) + + def format(self, record: logging.LogRecord) -> str: + payload = json.loads(self._json_formatter.format(record)) + timestamp = payload.get("timestamp", "") + level = _short_level(payload.get("level", "info")) + logger_name = str(payload.get("logger", "app")) + event = str(payload.get("event", "")) + + parts = [timestamp, level, logger_name, event] + + request_id = payload.pop("request_id", None) + method = payload.pop("method", None) + path = payload.pop("path", None) + status_code = payload.pop("status_code", None) + duration_ms = payload.pop("duration_ms", None) + + if request_id: + parts.append(f"rid={request_id}") + if method and path: + parts.append(f"{method} {path}") + if status_code is not None: + parts.append(str(status_code)) + if duration_ms is not None: + parts.append(f"{duration_ms}ms") + + for key in sorted(payload.keys()): + if key in {"timestamp", "level", "logger", "event", "exception"}: + continue + parts.append(f"{key}={payload[key]}") + + if "exception" in payload: + parts.append(f"exception={payload['exception']}") + + return " | ".join(str(part) for part in parts if part) + + +def _extra_fields(record: logging.LogRecord) -> dict[str, Any]: + extras: dict[str, Any] = {} + for key, value in record.__dict__.items(): + if key in _STANDARD_RECORD_FIELDS or key.startswith("_"): + continue + if key in _NOISY_RECORD_FIELDS: + continue + extras[key] = value + return extras + + +def _normalize_level(level: str) -> int: + normalized = level.strip().upper() + mapping = logging.getLevelNamesMapping() + if normalized not in mapping: + return logging.INFO + return mapping[normalized] + + +def _format_timestamp(created_ts: float) -> str: + dt = datetime.fromtimestamp(created_ts, tz=timezone.utc) + return dt.strftime("%Y-%m-%d %H:%M:%SZ") + + +def _short_level(level: str) -> str: + mapping = { + "debug": "DBG", + "info": "INF", + "warning": "WRN", + "error": "ERR", + "critical": "CRT", + } + return mapping.get(level.lower(), level[:3].upper()) diff --git a/build/lib/app/logging_context.py b/build/lib/app/logging_context.py new file mode 100644 index 0000000..ee578fb --- /dev/null +++ b/build/lib/app/logging_context.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from contextvars import ContextVar + + +_request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None) + + +def get_request_id() -> str | None: + return _request_id_ctx.get() + + +def set_request_id(value: str | None) -> None: + _request_id_ctx.set(value) + diff --git a/build/lib/app/main.py b/build/lib/app/main.py new file mode 100644 index 0000000..e628bd9 --- /dev/null +++ b/build/lib/app/main.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +import logging +from pathlib import Path + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +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.http.middleware import ( + RequestLoggingMiddleware, + SecurityHeadersMiddleware, + parse_skip_paths, +) +from app.logging_config import configure_logging, parse_redact_fields +from app.security.csrf import CSRFMiddleware +from app.services.domains.ingestion.scheduler import SchedulerService +from app.settings import settings + +logger = logging.getLogger(__name__) +BUILD_MARKER = "2026-02-19.phase2.direct-pdf-import-export-dashboard-sync" + +configure_logging( + level=settings.log_level, + log_format=settings.log_format, + redact_fields=parse_redact_fields(settings.log_redact_fields), + include_uvicorn_access=settings.log_uvicorn_access, +) + +scheduler_service = SchedulerService( + enabled=settings.scheduler_enabled, + tick_seconds=settings.scheduler_tick_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, + continuation_queue_enabled=settings.ingestion_continuation_queue_enabled, + continuation_base_delay_seconds=settings.ingestion_continuation_base_delay_seconds, + continuation_max_delay_seconds=settings.ingestion_continuation_max_delay_seconds, + continuation_max_attempts=settings.ingestion_continuation_max_attempts, + queue_batch_size=settings.scheduler_queue_batch_size, +) + + +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() + + from app.db.session import get_session_factory + from sqlalchemy import text + try: + session_factory = get_session_factory() + async with session_factory() as session: + await session.execute(text("UPDATE crawl_runs SET status = 'failed' WHERE status = 'running'")) + await session.commit() + logger.info("app.startup_orphaned_runs_cleaned", extra={"event": "app.startup_orphaned_runs_cleaned"}) + except Exception as e: + logger.error(f"Failed to clean orphaned runs at startup: {e}") + + await scheduler_service.start() + yield + await scheduler_service.stop() + await close_engine() + + +app = FastAPI(title=settings.app_name, lifespan=lifespan) +register_api_exception_handlers(app) +app.add_middleware(CSRFMiddleware) +app.add_middleware( + SessionMiddleware, + secret_key=settings.session_secret_key, + same_site="lax", + https_only=settings.session_cookie_secure, +) +app.add_middleware( + RequestLoggingMiddleware, + log_requests=settings.log_requests, + skip_paths=parse_skip_paths(settings.log_request_skip_paths), +) +app.add_middleware( + SecurityHeadersMiddleware, + enabled=settings.security_headers_enabled, + x_content_type_options=settings.security_x_content_type_options, + x_frame_options=settings.security_x_frame_options, + referrer_policy=settings.security_referrer_policy, + permissions_policy=settings.security_permissions_policy, + cross_origin_opener_policy=settings.security_cross_origin_opener_policy, + cross_origin_resource_policy=settings.security_cross_origin_resource_policy, + content_security_policy_enabled=settings.security_csp_enabled, + content_security_policy=settings.security_csp_policy, + content_security_policy_docs=settings.security_csp_docs_policy, + 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_preload=settings.security_strict_transport_security_preload, +) +app.include_router(api_router) +app.include_router(media_router) + + +@app.get("/healthz") +async def healthz() -> dict[str, str]: + if await check_database(): + return {"status": "ok"} + raise HTTPException(status_code=500, detail="database unavailable") + + +def _configure_frontend_routes(application: FastAPI) -> None: + if not settings.frontend_enabled: + return + + dist_dir = Path(settings.frontend_dist_dir) + index_file = dist_dir / "index.html" + + if not index_file.is_file(): + return + + assets_dir = dist_dir / "assets" + if assets_dir.is_dir(): + application.mount("/assets", StaticFiles(directory=assets_dir), name="frontend-assets") + + @application.get("/", include_in_schema=False) + async def frontend_index() -> FileResponse: + return FileResponse(index_file) + + @application.get("/{full_path:path}", include_in_schema=False) + async def frontend_entry(full_path: str) -> FileResponse: + normalized = full_path.lstrip("/") + if normalized in {"healthz", "api"} or normalized.startswith("api/"): + raise HTTPException(status_code=404, detail="Not Found") + + candidate = (dist_dir / normalized).resolve() + try: + candidate.relative_to(dist_dir.resolve()) + except ValueError as error: + raise HTTPException(status_code=404, detail="Not Found") from error + + if candidate.is_file(): + return FileResponse(candidate) + + return FileResponse(index_file) + + +_configure_frontend_routes(app) diff --git a/build/lib/app/security/__init__.py b/build/lib/app/security/__init__.py new file mode 100644 index 0000000..4033692 --- /dev/null +++ b/build/lib/app/security/__init__.py @@ -0,0 +1,2 @@ +"""Security middleware and helpers for scholarr.""" + diff --git a/build/lib/app/security/csrf.py b/build/lib/app/security/csrf.py new file mode 100644 index 0000000..70eecac --- /dev/null +++ b/build/lib/app/security/csrf.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import logging +from secrets import compare_digest, token_urlsafe +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.types import Message + + +CSRF_SESSION_KEY = "csrf_token" +CSRF_FORM_FIELD = "csrf_token" +CSRF_HEADER_NAME = "x-csrf-token" +SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"} +logger = logging.getLogger(__name__) + + +def ensure_csrf_token(request: Request) -> str: + token = request.session.get(CSRF_SESSION_KEY) + if token is None: + token = token_urlsafe(32) + request.session[CSRF_SESSION_KEY] = token + return str(token) + + +class CSRFMiddleware(BaseHTTPMiddleware): + def __init__(self, app, *, exempt_paths: set[str] | None = None) -> None: + super().__init__(app) + self._exempt_paths = exempt_paths or set() + + async def dispatch(self, request: Request, call_next) -> Response: + if self._should_skip(request): + return await call_next(request) + + session_token = request.session.get(CSRF_SESSION_KEY) + if not session_token: + logger.warning( + "csrf.missing_session_token", + extra={ + "event": "csrf.missing_session_token", + "method": request.method, + "path": request.url.path, + }, + ) + return self._csrf_error_response( + request, + code="csrf_missing", + message="CSRF token missing.", + ) + + request_token = request.headers.get(CSRF_HEADER_NAME) + 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] + + if not request_token or not compare_digest(str(session_token), str(request_token)): + logger.warning( + "csrf.invalid_token", + extra={ + "event": "csrf.invalid_token", + "method": request.method, + "path": request.url.path, + }, + ) + return self._csrf_error_response( + request, + code="csrf_invalid", + message="CSRF token invalid.", + ) + + return await call_next(request) + + def _should_skip(self, request: Request) -> bool: + if request.method in SAFE_METHODS: + return True + path = request.url.path + return path in self._exempt_paths + + def _is_form_payload(self, request: Request) -> bool: + content_type = request.headers.get("content-type", "") + return content_type.startswith("application/x-www-form-urlencoded") or ( + content_type.startswith("multipart/form-data") + ) + + def _token_from_form_body(self, request: Request, body: bytes) -> str | None: + 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) + values = parsed.get(CSRF_FORM_FIELD) + if not values: + return None + return values[0] + + def _build_receive(self, body: bytes): + consumed = False + + async def receive() -> Message: + nonlocal consumed + if consumed: + return {"type": "http.request", "body": b"", "more_body": False} + consumed = True + return {"type": "http.request", "body": body, "more_body": False} + + return receive + + def _csrf_error_response( + self, + request: Request, + *, + code: str, + 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, + }, + }, + status_code=403, + ) + return PlainTextResponse(message, status_code=403) diff --git a/build/lib/app/services/__init__.py b/build/lib/app/services/__init__.py new file mode 100644 index 0000000..167f869 --- /dev/null +++ b/build/lib/app/services/__init__.py @@ -0,0 +1,2 @@ +"""Service layer for scholarr application workflows.""" + diff --git a/build/lib/app/services/domains/__init__.py b/build/lib/app/services/domains/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/build/lib/app/services/domains/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/build/lib/app/services/domains/arxiv/application.py b/build/lib/app/services/domains/arxiv/application.py new file mode 100644 index 0000000..1581a7d --- /dev/null +++ b/build/lib/app/services/domains/arxiv/application.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING +import xml.etree.ElementTree as ET + +import httpx + +from app.services.domains.publication_identifiers.normalize import normalize_arxiv_id +from app.settings import settings + +if TYPE_CHECKING: + from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem + +logger = logging.getLogger(__name__) + + +def _build_arxiv_query(title: str, author_surname: str | None) -> str | None: + parts = [] + if title: + # arXiv api allows strict title searching using ti: + clean_title = title.replace('"', '').replace("'", "") + parts.append(f'ti:"{clean_title}"') + if author_surname: + parts.append(f'au:"{author_surname}"') + + if not parts: + return None + return " AND ".join(parts) + + +async def discover_arxiv_id_for_publication( + *, + item: PublicationListItem | UnreadPublicationItem, + request_email: str | None = None, + timeout_seconds: float = 3.0, +) -> str | None: + title = (item.title or "").strip() + if not title: + return None + + author_surname = None + if item.scholar_label: + tokens = [t for t in item.scholar_label.strip().split() if t] + if tokens: + author_surname = tokens[-1].lower() + + query = _build_arxiv_query(title, author_surname) + if not query: + return None + + url = "https://export.arxiv.org/api/query" + params = { + "search_query": query, + "start": 0, + "max_results": 3, + } + + headers = {"User-Agent": f"scholar-scraper/1.0 (mailto:{request_email or settings.crossref_api_mailto or 'unknown@example.com'})"} + + try: + async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True, headers=headers) as client: + response = await client.get(url, params=params) + response.raise_for_status() + + root = ET.fromstring(response.text) + namespace = {'atom': 'http://www.w3.org/2005/Atom'} + + for entry in root.findall('atom:entry', namespace): + id_elem = entry.find('atom:id', namespace) + if id_elem is not None and id_elem.text: + candidate = str(id_elem.text) + if '/abs/' in candidate: + candidate = candidate.split('/abs/')[-1] + normalized = normalize_arxiv_id(candidate) + if normalized: + logger.debug("arxiv.id_discovered", extra={"event": "arxiv.id_discovered", "arxiv_id": normalized}) + return normalized + + except Exception as exc: + logger.debug(f"Failed to query arXiv API: {exc}") + + return None diff --git a/build/lib/app/services/domains/crossref/__init__.py b/build/lib/app/services/domains/crossref/__init__.py new file mode 100644 index 0000000..8adb7ba --- /dev/null +++ b/build/lib/app/services/domains/crossref/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from app.services.domains.crossref.application import discover_doi_for_publication + +__all__ = ["discover_doi_for_publication"] diff --git a/build/lib/app/services/domains/crossref/application.py b/build/lib/app/services/domains/crossref/application.py new file mode 100644 index 0000000..f0e1311 --- /dev/null +++ b/build/lib/app/services/domains/crossref/application.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +import asyncio +import logging +import re +import threading +import time +from typing import TYPE_CHECKING + +from crossref.restful import Etiquette, Works + +from app.services.domains.doi.normalize import normalize_doi +from app.settings import settings + +if TYPE_CHECKING: + from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem + +TOKEN_RE = re.compile(r"[a-z0-9]+") +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: + global _LAST_REQUEST_AT + interval = max(float(min_interval_seconds), 0.0) + with _RATE_LOCK: + elapsed = time.monotonic() - _LAST_REQUEST_AT + remaining = interval - elapsed + if remaining > 0: + time.sleep(remaining) + _LAST_REQUEST_AT = time.monotonic() + + +def _normalized_tokens(value: str) -> list[str]: + 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] + + +def _normalized_query(value: str) -> str: + tokens = [token for token in _normalized_tokens(value) if token not in STOP_WORDS] + if len(tokens) < 3: + tokens = _normalized_tokens(value) + if len(tokens) < 3: + return "" + return " ".join(tokens[:12]).strip() + + +def _query_author(value: str) -> str | None: + tokens = [token for token in value.strip().split() if token] + if len(tokens) < 2: + return None + return " ".join(tokens[:2])[:64] + + +def _author_surname(value: str) -> str | None: + tokens = [token for token in value.strip().split() if token] + if not tokens: + return None + return NON_ALNUM_RE.sub("", tokens[-1].lower()) or None + + +def _query_filters(year: int | None) -> list[tuple[str, str] | None]: + if year is None: + return [None] + return [ + (f"{year - 1}-01-01", f"{year + 1}-12-31"), + (f"{year}-01-01", f"{year}-12-31"), + None, + ] + + +def _candidate_title(item: dict) -> str: + titles = item.get("title") + if isinstance(titles, list) and titles: + return str(titles[0] or "") + return str(item.get("title") or "") + + +def _title_match_score(source: str, candidate: str) -> float: + source_tokens = {token for token in _normalized_tokens(source) if len(token) >= 3} + candidate_tokens = {token for token in _normalized_tokens(candidate) if len(token) >= 3} + if not source_tokens or not candidate_tokens: + return 0.0 + return len(source_tokens & candidate_tokens) / float(len(source_tokens)) + + +def _candidate_year(item: dict) -> int | None: + issued = item.get("issued") + if not isinstance(issued, dict): + return None + date_parts = issued.get("date-parts") + if not isinstance(date_parts, list) or not date_parts: + return None + first = date_parts[0] + if not isinstance(first, list) or not first: + return None + try: + return int(first[0]) + except (TypeError, ValueError): + return None + + +def _candidate_author_match(item: dict, surname: str | None) -> bool: + if not surname: + return True + authors = item.get("author") + if not isinstance(authors, list): + return False + for author in authors: + if not isinstance(author, dict): + continue + family = NON_ALNUM_RE.sub("", str(author.get("family") or "").lower()) + if family and family == surname: + return True + return False + + +def _candidate_rank(*, title: str, year: int | None, item: dict) -> 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)) + candidate_year = _candidate_year(item) + if year is not None and candidate_year is not None: + if abs(year - candidate_year) > 1: + return 0.0, None + score += 0.1 + return score, 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, + items: list[dict], + author_surname: str | None, +) -> str | None: + best_score = 0.0 + best_doi: str | None = None + best_year: int | None = None + for item in items: + if not isinstance(item, dict): + continue + if not _candidate_author_match(item, author_surname): + continue + score, doi = _candidate_rank(title=title, year=year, item=item) + candidate_year = _candidate_year(item) + if doi is None or score < STRICT_TITLE_MATCH_THRESHOLD: + continue + if score > best_score: + best_score = score + best_doi = doi + best_year = candidate_year + continue + if abs(score - best_score) > 0.02: + continue + if best_year is None or candidate_year is None: + continue + if candidate_year < best_year: + best_doi = doi + best_year = candidate_year + 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) + return Works(etiquette=etiquette) + return Works() + + +def _fetch_items_sync( + *, + query: str, + author: str | None, + date_range: tuple[str, str] | None, + max_rows: int, + email: str | None, + min_interval_seconds: float, +) -> list[dict]: + _rate_limit_wait(min_interval_seconds) + works = _works_client(email) + params = {"bibliographic": query} + if author: + params["author"] = author + request = works.query(**params) + if date_range is not None: + from_date, until_date = date_range + request = request.filter(from_pub_date=from_date, until_pub_date=until_date) + request = request.select(["DOI", "title", "issued", "score", "author"]) + items: list[dict] = [] + for entry in request: + if isinstance(entry, dict): + items.append(entry) + if len(items) >= max(max_rows, 1): + break + return items + + +async def _fetch_items( + *, + query: str, + author: str | None, + date_range: tuple[str, str] | None, + max_rows: int, + email: str | None, +) -> list[dict]: + timeout = max(float(settings.crossref_timeout_seconds), 0.5) + try: + return await asyncio.wait_for( + asyncio.to_thread( + _fetch_items_sync, + query=query, + author=author, + date_range=date_range, + max_rows=max_rows, + email=email, + min_interval_seconds=settings.crossref_min_interval_seconds, + ), + timeout=timeout, + ) + except Exception: + return [] + + +async def discover_doi_for_publication( + *, + item: PublicationListItem | UnreadPublicationItem, + max_rows: int = 10, + email: str | None = None, +) -> str | None: + title = (item.title or "").strip() + query = _normalized_query(title) + if not query: + return None + author = _query_author(item.scholar_label) + author_surname = _author_surname(item.scholar_label) + for date_range in _query_filters(item.year): + items = await _fetch_items( + query=query, + author=author, + date_range=date_range, + max_rows=max_rows, + email=email, + ) + doi = _best_candidate_doi( + title=title, + year=item.year, + items=items, + author_surname=author_surname, + ) + if doi: + logger.debug("crossref.doi_discovered", extra={"event": "crossref.doi_discovered"}) + return doi + return None diff --git a/build/lib/app/services/domains/dbops/__init__.py b/build/lib/app/services/domains/dbops/__init__.py new file mode 100644 index 0000000..f5b02e4 --- /dev/null +++ b/build/lib/app/services/domains/dbops/__init__.py @@ -0,0 +1,5 @@ +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/build/lib/app/services/domains/dbops/application.py b/build/lib/app/services/domains/dbops/application.py new file mode 100644 index 0000000..e7ace77 --- /dev/null +++ b/build/lib/app/services/domains/dbops/application.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import 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" +REPAIR_STATUS_FAILED = "failed" +SCOPE_MODE_SINGLE_USER = "single_user" +SCOPE_MODE_ALL_USERS = "all_users" + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _normalize_scope_mode(scope_mode: str) -> str: + normalized = scope_mode.strip().lower() + if normalized in {SCOPE_MODE_SINGLE_USER, SCOPE_MODE_ALL_USERS}: + return normalized + raise ValueError("Unknown scope mode.") + + +def _scope_user_id(*, scope_mode: str, user_id: int | None) -> int | None: + if scope_mode == SCOPE_MODE_SINGLE_USER: + if user_id is None: + raise ValueError("user_id is required when scope_mode=single_user.") + return int(user_id) + if user_id is not None: + raise ValueError("user_id must be omitted when scope_mode=all_users.") + return None + + +def _scope_payload( + *, + scope_mode: str, + user_id: int | None, + target_scholar_profile_ids: list[int], + orphan_gc: bool, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "scope_mode": scope_mode, + "scholar_profile_ids": [int(value) for value in target_scholar_profile_ids], + "gc_orphan_publications": bool(orphan_gc), + } + if user_id is not None: + payload["user_id"] = int(user_id) + return payload + + +async def _target_scholar_profile_ids( + db_session: AsyncSession, + *, + scope_mode: str, + user_id: int | None, + scholar_profile_ids: list[int] | None, +) -> list[int]: + stmt = select(ScholarProfile.id) + if scope_mode == SCOPE_MODE_SINGLE_USER: + stmt = stmt.where(ScholarProfile.user_id == user_id) + if scholar_profile_ids: + normalized_ids = [int(value) for value in scholar_profile_ids] + stmt = stmt.where(ScholarProfile.id.in_(normalized_ids)) + result = await db_session.execute(stmt.order_by(ScholarProfile.id.asc())) + ids = [int(row[0]) for row in result.all()] + if not ids: + raise ValueError("No target scholar profiles found for the requested scope.") + return ids + + +async def _count_scope( + db_session: AsyncSession, + *, + user_id: int | None, + target_scholar_profile_ids: list[int], +) -> dict[str, int]: + links_result = await db_session.execute( + select(func.count()) + .select_from(ScholarPublication) + .where(ScholarPublication.scholar_profile_id.in_(target_scholar_profile_ids)) + ) + queue_stmt = ( + select(func.count()) + .select_from(IngestionQueueItem) + .where(IngestionQueueItem.scholar_profile_id.in_(target_scholar_profile_ids)) + ) + if user_id is not None: + queue_stmt = queue_stmt.where(IngestionQueueItem.user_id == user_id) + queue_result = await db_session.execute(queue_stmt) + return { + "target_scholar_count": len(target_scholar_profile_ids), + "links_in_scope": int(links_result.scalar_one() or 0), + "queue_items_in_scope": int(queue_result.scalar_one() or 0), + } + + +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) + ) + ) + ) + result = await db_session.execute(stmt) + return int(result.scalar_one() or 0) + + +async def _create_job( + db_session: AsyncSession, + *, + requested_by: str | None, + scope: dict[str, Any], + dry_run: bool, +) -> DataRepairJob: + job = DataRepairJob( + job_name="repair_publication_links", + requested_by=(requested_by or "").strip() or None, + scope=scope, + dry_run=dry_run, + status=REPAIR_STATUS_PLANNED, + summary={}, + ) + db_session.add(job) + await db_session.flush() + return job + + +def _job_summary( + *, + counts: dict[str, int], + dry_run: bool, + links_deleted: int, + queue_items_deleted: int, + scholars_reset: int, + orphan_publications_before: int, + orphan_publications_deleted: int, +) -> dict[str, Any]: + return { + **counts, + "dry_run": bool(dry_run), + "links_deleted": int(links_deleted), + "queue_items_deleted": int(queue_items_deleted), + "scholars_reset": int(scholars_reset), + "orphan_publications_before": int(orphan_publications_before), + "orphan_publications_deleted": int(orphan_publications_deleted), + } + + +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) + ) + ) + return int(result.rowcount or 0) + + +async def _delete_queue_for_targets( + db_session: AsyncSession, + *, + user_id: int | None, + target_scholar_profile_ids: list[int], +) -> int: + 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) + return int(result.rowcount or 0) + + +async def _reset_scholar_tracking_state( + db_session: AsyncSession, + *, + user_id: int | None, + target_scholar_profile_ids: list[int], +) -> int: + 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( + stmt.values( + baseline_completed=False, + last_initial_page_fingerprint_sha256=None, + last_initial_page_checked_at=None, + last_run_dt=None, + last_run_status=None, + ) + ) + return int(result.rowcount or 0) + + +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)) + ) + ) + return int(result.rowcount or 0) + + +async def _mutation_counts( + db_session: AsyncSession, + *, + user_id: int | None, + target_ids: list[int], + dry_run: bool, + gc_orphan_publications: bool, +) -> tuple[int, int, int, int]: + if dry_run: + return 0, 0, 0, 0 + links_deleted = await _delete_links_for_targets( + db_session, + target_scholar_profile_ids=target_ids, + ) + queue_deleted = await _delete_queue_for_targets( + db_session, + user_id=user_id, + target_scholar_profile_ids=target_ids, + ) + scholars_reset = await _reset_scholar_tracking_state( + db_session, + user_id=user_id, + target_scholar_profile_ids=target_ids, + ) + orphan_deleted = 0 + if gc_orphan_publications: + orphan_deleted = await _delete_orphan_publications(db_session) + return links_deleted, queue_deleted, scholars_reset, orphan_deleted + + +def _result_payload(*, job: DataRepairJob, scope: dict[str, Any], summary: dict[str, Any]) -> dict[str, Any]: + return { + "job_id": int(job.id), + "status": job.status, + "scope": scope, + "summary": summary, + } + + +async def _complete_job( + db_session: AsyncSession, + *, + job: DataRepairJob, + summary: dict[str, Any], + scope: dict[str, Any], +) -> dict[str, Any]: + job.summary = summary + job.status = REPAIR_STATUS_COMPLETED + job.finished_at = _utcnow() + await db_session.commit() + return _result_payload(job=job, scope=scope, summary=summary) + + +async def _fail_job(db_session: AsyncSession, *, job: DataRepairJob, error: Exception) -> None: + await db_session.rollback() + job.status = REPAIR_STATUS_FAILED + job.error_text = str(error) + job.finished_at = _utcnow() + db_session.add(job) + await db_session.commit() + + +async def _prepare_repair_job( + db_session: AsyncSession, + *, + scope_mode: str, + user_id: int | None, + scholar_profile_ids: list[int] | None, + dry_run: bool, + gc_orphan_publications: bool, + requested_by: str | None, +) -> tuple[int | None, list[int], dict[str, Any], DataRepairJob]: + normalized_scope = _normalize_scope_mode(scope_mode) + scope_user_id = _scope_user_id(scope_mode=normalized_scope, user_id=user_id) + target_ids = await _target_scholar_profile_ids( + db_session, + scope_mode=normalized_scope, + user_id=scope_user_id, + scholar_profile_ids=scholar_profile_ids, + ) + scope = _scope_payload( + scope_mode=normalized_scope, + user_id=scope_user_id, + target_scholar_profile_ids=target_ids, + orphan_gc=gc_orphan_publications, + ) + job = await _create_job(db_session, requested_by=requested_by, scope=scope, dry_run=dry_run) + job.status = REPAIR_STATUS_RUNNING + job.started_at = _utcnow() + return scope_user_id, target_ids, scope, job + + +async def _build_repair_summary( + db_session: AsyncSession, + *, + scope_user_id: int | None, + target_ids: list[int], + dry_run: bool, + gc_orphan_publications: bool, +) -> dict[str, Any]: + counts = await _count_scope(db_session, user_id=scope_user_id, target_scholar_profile_ids=target_ids) + orphan_before = await _count_orphan_publications(db_session) + links_deleted, queue_deleted, scholars_reset, orphan_deleted = await _mutation_counts( + db_session, + user_id=scope_user_id, + target_ids=target_ids, + dry_run=dry_run, + gc_orphan_publications=gc_orphan_publications, + ) + return _job_summary( + counts=counts, + dry_run=dry_run, + links_deleted=links_deleted, + queue_items_deleted=queue_deleted, + scholars_reset=scholars_reset, + orphan_publications_before=orphan_before, + orphan_publications_deleted=orphan_deleted, + ) + + +async def run_publication_link_repair( + db_session: AsyncSession, + *, + scope_mode: str = SCOPE_MODE_SINGLE_USER, + user_id: int | None = None, + scholar_profile_ids: list[int] | None = None, + dry_run: bool = True, + gc_orphan_publications: bool = False, + requested_by: str | None = None, +) -> dict[str, Any]: + scope_user_id, target_ids, scope, job = await _prepare_repair_job( + db_session, + scope_mode=scope_mode, + user_id=user_id, + scholar_profile_ids=scholar_profile_ids, + dry_run=dry_run, + gc_orphan_publications=gc_orphan_publications, + requested_by=requested_by, + ) + + try: + summary = await _build_repair_summary( + db_session, + scope_user_id=scope_user_id, + target_ids=target_ids, + dry_run=dry_run, + gc_orphan_publications=gc_orphan_publications, + ) + return await _complete_job(db_session, job=job, summary=summary, scope=scope) + except Exception as exc: + await _fail_job(db_session, job=job, error=exc) + raise diff --git a/build/lib/app/services/domains/dbops/integrity.py b/build/lib/app/services/domains/dbops/integrity.py new file mode 100644 index 0000000..4d9c43c --- /dev/null +++ b/build/lib/app/services/domains/dbops/integrity.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Publication + +INTEGRITY_CHECK_DEFS = ( + ( + "legacy_cluster_id_format", + "warning", + "Publications with non-namespaced cluster IDs.", + ), + ( + "negative_citation_count", + "failure", + "Publications with negative citation counts.", + ), + ( + "orphan_publications", + "warning", + "Publications with no scholar links.", + ), + ( + "orphan_scholar_publication_links", + "failure", + "Link rows missing parent scholar/publication.", + ), + ( + "duplicate_fingerprint_keys", + "failure", + "Duplicate publication fingerprint keys.", + ), + ( + "duplicate_cluster_ids", + "failure", + "Duplicate non-null publication cluster IDs.", + ), + ( + "missing_pdf_url", + "metric", + "Publications without a resolved PDF URL.", + ), +) + + +async def _legacy_cluster_id_count(db_session: AsyncSession) -> int: + stmt = ( + select(func.count()) + .select_from(Publication) + .where(Publication.cluster_id.is_not(None)) + .where(~Publication.cluster_id.like("cfv:%")) + .where(~Publication.cluster_id.like("cluster:%")) + ) + result = await db_session.execute(stmt) + return int(result.scalar_one() or 0) + + +async def _negative_citation_count(db_session: AsyncSession) -> int: + result = await db_session.execute( + select(func.count()).select_from(Publication).where(Publication.citation_count < 0) + ) + return int(result.scalar_one() or 0) + + +async def _missing_pdf_url_count(db_session: AsyncSession) -> int: + result = await db_session.execute( + select(func.count()).select_from(Publication).where(Publication.pdf_url.is_(None)) + ) + return int(result.scalar_one() or 0) + + +async def _count_from_sql(db_session: AsyncSession, *, sql: str) -> int: + result = await db_session.execute(text(sql)) + return int(result.scalar_one() or 0) + + +def _issues_for_severity(*, checks: list[dict[str, Any]], severity: str) -> list[str]: + return [row["name"] for row in checks if row["severity"] == severity and row["count"] > 0] + + +def _check_row(*, name: str, count: int, severity: str, message: str) -> dict[str, Any]: + return { + "name": name, + "count": int(count), + "severity": severity, + "message": message, + } + + +async def _collect_counts(db_session: AsyncSession) -> dict[str, int]: + orphan_publications = await _count_from_sql( + db_session, + sql=( + "SELECT count(*) FROM publications p " + "LEFT JOIN scholar_publications sp ON sp.publication_id = p.id " + "WHERE sp.publication_id IS NULL" + ), + ) + orphan_links = await _count_from_sql( + db_session, + sql=( + "SELECT count(*) FROM scholar_publications sp " + "LEFT JOIN publications p ON p.id = sp.publication_id " + "LEFT JOIN scholar_profiles s ON s.id = sp.scholar_profile_id " + "WHERE p.id IS NULL OR s.id IS NULL" + ), + ) + duplicate_fingerprints = await _count_from_sql( + db_session, + sql=( + "SELECT count(*) FROM (" + "SELECT fingerprint_sha256 FROM publications " + "GROUP BY fingerprint_sha256 HAVING count(*) > 1" + ") dup" + ), + ) + duplicate_cluster_ids = await _count_from_sql( + db_session, + sql=( + "SELECT count(*) FROM (" + "SELECT cluster_id FROM publications " + "WHERE cluster_id IS NOT NULL " + "GROUP BY cluster_id HAVING count(*) > 1" + ") dup" + ), + ) + return { + "legacy_cluster_id_format": await _legacy_cluster_id_count(db_session), + "negative_citation_count": await _negative_citation_count(db_session), + "orphan_publications": orphan_publications, + "orphan_scholar_publication_links": orphan_links, + "duplicate_fingerprint_keys": duplicate_fingerprints, + "duplicate_cluster_ids": duplicate_cluster_ids, + "missing_pdf_url": await _missing_pdf_url_count(db_session), + } + + +def _build_checks(*, counts: dict[str, int]) -> list[dict[str, Any]]: + return [ + _check_row( + name=name, + count=counts[name], + severity=severity, + message=message, + ) + for name, severity, message in INTEGRITY_CHECK_DEFS + ] + + +def _status_from_issues(*, failures: list[str], warnings: list[str]) -> str: + status = "ok" + if failures: + status = "failed" + elif warnings: + status = "warning" + return status + + +async def collect_integrity_report(db_session: AsyncSession) -> dict[str, Any]: + counts = await _collect_counts(db_session) + checks = _build_checks(counts=counts) + failures = _issues_for_severity(checks=checks, severity="failure") + warnings = _issues_for_severity(checks=checks, severity="warning") + + return { + "status": _status_from_issues(failures=failures, warnings=warnings), + "checked_at": datetime.now(timezone.utc).isoformat(), + "failures": failures, + "warnings": warnings, + "checks": checks, + } diff --git a/build/lib/app/services/domains/dbops/query.py b/build/lib/app/services/domains/dbops/query.py new file mode 100644 index 0000000..2e95e31 --- /dev/null +++ b/build/lib/app/services/domains/dbops/query.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import DataRepairJob + + +def _bounded_limit(limit: int) -> int: + return max(1, min(int(limit), 200)) + + +async def list_repair_jobs( + db_session: AsyncSession, + *, + limit: int = 50, +) -> list[DataRepairJob]: + bounded = _bounded_limit(limit) + result = await db_session.execute( + select(DataRepairJob).order_by(DataRepairJob.created_at.desc()).limit(bounded) + ) + return list(result.scalars()) diff --git a/build/lib/app/services/domains/doi/normalize.py b/build/lib/app/services/domains/doi/normalize.py new file mode 100644 index 0000000..4719cf3 --- /dev/null +++ b/build/lib/app/services/domains/doi/normalize.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import re +from urllib.parse import unquote + +DOI_RE = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.I) + + +def normalize_doi(value: str | None) -> str | None: + if not value: + return None + match = DOI_RE.search(unquote(value)) + if not match: + return None + return match.group(0).rstrip(" .;,)").lower() + + +def first_doi_from_texts(*values: str | None) -> str | None: + for value in values: + doi = normalize_doi(value) + if doi: + return doi + return None diff --git a/build/lib/app/services/domains/ingestion/__init__.py b/build/lib/app/services/domains/ingestion/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/build/lib/app/services/domains/ingestion/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/build/lib/app/services/domains/ingestion/application.py b/build/lib/app/services/domains/ingestion/application.py new file mode 100644 index 0000000..63656b4 --- /dev/null +++ b/build/lib/app/services/domains/ingestion/application.py @@ -0,0 +1,2413 @@ +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.publication_identifiers import application as identifier_service +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.runs.events import run_events +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 + 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: + # We no longer aggregate and upsert the publications here. + # Eager DB insertions have already saved and committed them inside `_fetch_and_parse_all_pages_with_retry` and `_paginate_loop`. + 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"] = 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, + 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: + 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, + 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, + 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, + 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: + run_dt, paged_parse_result, result_entry = await self._fetch_and_prepare_scholar_result( + db_session, + 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 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, + db_session: AsyncSession, + *, + 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(timezone.utc) + paged_parse_result = await self._fetch_and_parse_all_pages_with_retry( + 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, + ) + 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) + if run.status != RunStatus.CANCELED: + 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, + rate_limit_retries: int, + rate_limit_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, + "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, + } + + @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, rate_limit_retries: int, rate_limit_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, + 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: + from app.db.models import RunStatus + + progress = RunProgress() + for index, scholar in enumerate(scholars): + await db_session.refresh(run) + if run.status == RunStatus.CANCELED: + logger.info( + "ingestion.run_canceled", + extra={"event": "ingestion.run_canceled", "run_id": run.id, "user_id": user_id}, + ) + break + 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, + 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, + 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, rate_limit_retries=settings.ingestion_rate_limit_retries, rate_limit_backoff_seconds=settings.ingestion_rate_limit_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_page_fetch( + *, + 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_retry_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 + + logger.warning( + "ingestion.scholar_retry_scheduled", + extra={ + "event": "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) + + 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, + 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_profile_page_or_layout_error(fetch_result=fetch_result) + + if parsed_page.state == ParseState.NETWORK_ERROR: + network_attempts += 1 + total_attempts = network_attempts + elif parsed_page.state == ParseState.BLOCKED_OR_CAPTCHA and parsed_page.state_reason == "blocked_http_429_rate_limited": + rate_limit_attempts += 1 + total_attempts = rate_limit_attempts + else: + total_attempts = network_attempts + rate_limit_attempts + 1 + + attempt_log.append( + self._attempt_log_entry( + attempt=total_attempts, + cstart=cstart, + fetch_result=fetch_result, + parsed_page=parsed_page, + ) + ) + + if not self._should_retry_page_fetch( + 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_retry_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 + + 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, + 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, + ) + + @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, + 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._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, + 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( + 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, + 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._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, + 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 = [ + 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: ScholarProfile, + run: CrawlRun, + db_session: AsyncSession, + 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, + ) -> None: + if state.parsed_page.publications: + discovered_count = await self._upsert_profile_publications( + db_session, run=run, scholar=scholar, publications=list(state.parsed_page.publications) + ) + state.discovered_publication_count += discovered_count + await db_session.commit() + + while state.parsed_page.has_show_more_button: + from app.db.models import RunStatus + await db_session.refresh(run) + if run.status == RunStatus.CANCELED: + logger.info( + "ingestion.pagination_canceled", + extra={"event": "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, + ) + + # Immediately commit the new page's publications to the DB + if next_parsed_page.publications: + discovered_count = await self._upsert_profile_publications( + db_session, run=run, scholar=scholar, publications=list(next_parsed_page.publications) + ) + state.discovered_publication_count += discovered_count + await db_session.commit() + + 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, + 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 + + async def _fetch_and_parse_all_pages_with_retry( + self, + *, + scholar: ScholarProfile, + run: CrawlRun, + db_session: AsyncSession, + 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 + ) -> 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, + ) + 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 _enrich_publications_with_openalex( + self, + scholar: ScholarProfile, + publications: list[PublicationCandidate], + ) -> list[PublicationCandidate]: + if not publications: + return publications + + from app.services.domains.openalex.client import OpenAlexClient + from app.services.domains.openalex.matching import find_best_match + + client = OpenAlexClient(api_key=settings.openalex_api_key, mailto=settings.crossref_api_mailto) + + # Batch requests by 25 to stay within URL length limits + batch_size = 25 + enriched: list[PublicationCandidate] = [] + + import re + for i in range(0, len(publications), batch_size): + batch = publications[i:i + batch_size] + + titles = [] + for p in batch: + if not p.title: + continue + # Strip all non-alphanumeric words to prevent OpenAlex API injection crashes + # and minimize punctuation-based duplicate misses. + safe_title = re.sub(r"[^\w\s]", " ", p.title) + safe_title = " ".join(safe_title.split()) + if safe_title: + titles.append(safe_title) + + if not titles: + enriched.extend(batch) + continue + + query = "|".join(t for t in titles) + try: + openalex_works = await client.get_works_by_filter( + {"title.search": query}, limit=batch_size * 3 + ) + except Exception as e: + logger.warning( + "ingestion.openalex_enrichment_failed", + extra={"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.name, + 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 or p.year, + citation_count=match.cited_by_count, + authors_text=p.authors_text, + venue_text=p.venue_text, + pdf_url=match.oa_url or p.pdf_url, + ) + enriched.append(new_p) + else: + enriched.append(p) + + return enriched + + async def _upsert_profile_publications( + self, + db_session: AsyncSession, + *, + run: CrawlRun, + scholar: ScholarProfile, + publications: list[PublicationCandidate], + ) -> int: + publications = await self._enrich_publications_with_openalex(scholar, publications) + + 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, + }, + ) + + # Commit immediately to ensure the row becomes visible to the UI immediately + await db_session.commit() + + # Emit Server-Sent Event for live UI progress updates + 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(timezone.utc).isoformat(), + } + ) + + 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), + 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) + + 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: + created = await self._create_publication( + db_session, + candidate=candidate, + fingerprint=fingerprint, + ) + await identifier_service.discover_and_sync_identifiers_for_publication( + db_session, + publication=created, + scholar_label=candidate.authors_text or "", + ) + return created + self._update_existing_publication( + publication=publication, + candidate=candidate, + ) + await identifier_service.discover_and_sync_identifiers_for_publication( + db_session, + publication=publication, + scholar_label=candidate.authors_text or "", + ) + 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/build/lib/app/services/domains/ingestion/constants.py b/build/lib/app/services/domains/ingestion/constants.py new file mode 100644 index 0000000..cfe2ba0 --- /dev/null +++ b/build/lib/app/services/domains/ingestion/constants.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import re + +from app.services.domains.scholar.parser import ParseState + +TITLE_ALNUM_RE = re.compile(r"[^a-z0-9]+") +WORD_RE = re.compile(r"[a-z0-9]+") +HTML_TAG_RE = re.compile(r"<[^>]+>", re.S) +SPACE_RE = re.compile(r"\s+") + +FAILED_STATES = { + ParseState.BLOCKED_OR_CAPTCHA.value, + ParseState.LAYOUT_CHANGED.value, + ParseState.NETWORK_ERROR.value, + "ingestion_error", +} + +FAILURE_BUCKET_BLOCKED = "blocked_or_captcha" +FAILURE_BUCKET_NETWORK = "network_error" +FAILURE_BUCKET_LAYOUT = "layout_changed" +FAILURE_BUCKET_INGESTION = "ingestion_error" +FAILURE_BUCKET_OTHER = "other_failure" + +RUN_LOCK_NAMESPACE = 8217 +RESUMABLE_PARTIAL_REASONS = { + "max_pages_reached", + "pagination_cursor_stalled", +} +RESUMABLE_PARTIAL_REASON_PREFIXES = ("page_state_network_error",) +INITIAL_PAGE_FINGERPRINT_MAX_PUBLICATIONS = 30 diff --git a/build/lib/app/services/domains/ingestion/fingerprints.py b/build/lib/app/services/domains/ingestion/fingerprints.py new file mode 100644 index 0000000..b59710e --- /dev/null +++ b/build/lib/app/services/domains/ingestion/fingerprints.py @@ -0,0 +1,175 @@ +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 _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) + if not tokens_a or not tokens_b: + return False + intersection = tokens_a & tokens_b + union = tokens_a | tokens_b + return (len(intersection) / len(union)) >= threshold + + +def _dedupe_publication_candidates( + publications: list[PublicationCandidate], +) -> list[PublicationCandidate]: + deduped: list[PublicationCandidate] = [] + seen: set[str] = set() + seen_titles: list[tuple[str, int]] = [] # (normalized_title, index into deduped) + + 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 "", + _first_author_last_name(publication.authors_text), + _first_venue_word(publication.venue_text), + ] + ) + if identity in seen: + continue + + # Fuzzy title check — catch near-identical titles not caught by exact fingerprint + norm_title = normalize_title(publication.title) + is_fuzzy_dup = False + for existing_title, _idx in seen_titles: + if fuzzy_titles_match(norm_title, existing_title): + is_fuzzy_dup = True + break + if is_fuzzy_dup: + continue + + seen.add(identity) + seen_titles.append((norm_title, len(deduped))) + 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/build/lib/app/services/domains/ingestion/queue.py b/build/lib/app/services/domains/ingestion/queue.py new file mode 100644 index 0000000..7494420 --- /dev/null +++ b/build/lib/app/services/domains/ingestion/queue.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import IngestionQueueItem, QueueItemStatus + + +@dataclass(frozen=True) +class ContinuationQueueJob: + id: int + user_id: int + scholar_profile_id: int + resume_cstart: int + reason: str + status: str + attempt_count: int + next_attempt_dt: datetime + + +ACTIVE_QUEUE_STATUSES: tuple[str, ...] = ( + QueueItemStatus.QUEUED.value, + QueueItemStatus.RETRYING.value, +) + + +def normalize_cstart(value: int | None) -> int: + if value is None: + return 0 + return max(0, int(value)) + + +def compute_backoff_seconds(*, base_seconds: int, attempt_count: int, max_seconds: int) -> int: + base = max(1, int(base_seconds)) + attempts = max(1, int(attempt_count)) + maximum = max(base, int(max_seconds)) + seconds = base * (2 ** max(0, attempts - 1)) + return min(seconds, maximum) + + +async def _get_item_for_user_scholar( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int, +) -> IngestionQueueItem | None: + result = await db_session.execute( + select(IngestionQueueItem).where( + IngestionQueueItem.user_id == user_id, + IngestionQueueItem.scholar_profile_id == scholar_profile_id, + ) + ) + return result.scalar_one_or_none() + + +def _build_queue_item( + *, + now: datetime, + user_id: int, + scholar_profile_id: int, + normalized_cstart: int, + reason: str, + run_id: int | None, + next_attempt_dt: datetime, +) -> IngestionQueueItem: + return IngestionQueueItem( + user_id=user_id, + scholar_profile_id=scholar_profile_id, + resume_cstart=normalized_cstart, + reason=reason, + status=QueueItemStatus.QUEUED.value, + attempt_count=0, + next_attempt_dt=next_attempt_dt, + last_run_id=run_id, + last_error=None, + dropped_reason=None, + dropped_at=None, + created_at=now, + updated_at=now, + ) + + +def _update_existing_queue_item( + *, + item: IngestionQueueItem, + now: datetime, + normalized_cstart: int, + reason: str, + run_id: int | None, + next_attempt_dt: datetime, +) -> None: + item.resume_cstart = normalized_cstart + item.reason = reason + if item.status == QueueItemStatus.DROPPED.value: + item.attempt_count = 0 + item.status = QueueItemStatus.QUEUED.value + item.next_attempt_dt = next_attempt_dt + item.last_run_id = run_id + item.last_error = None + item.dropped_reason = None + item.dropped_at = None + item.updated_at = now + + +async def upsert_job( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int, + resume_cstart: int, + reason: str, + run_id: int | None, + delay_seconds: int, +) -> IngestionQueueItem: + now = datetime.now(timezone.utc) + next_attempt_dt = now + timedelta(seconds=max(0, int(delay_seconds))) + item = await _get_item_for_user_scholar( + db_session, + user_id=user_id, + scholar_profile_id=scholar_profile_id, + ) + normalized_cstart = normalize_cstart(resume_cstart) + if item is None: + item = _build_queue_item( + now=now, + user_id=user_id, + scholar_profile_id=scholar_profile_id, + normalized_cstart=normalized_cstart, + reason=reason, + run_id=run_id, + next_attempt_dt=next_attempt_dt, + ) + db_session.add(item) + return item + + _update_existing_queue_item( + item=item, + now=now, + normalized_cstart=normalized_cstart, + reason=reason, + run_id=run_id, + next_attempt_dt=next_attempt_dt, + ) + return item + + +async def clear_job_for_scholar( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int, +) -> bool: + result = await db_session.execute( + select(IngestionQueueItem).where( + IngestionQueueItem.user_id == user_id, + IngestionQueueItem.scholar_profile_id == scholar_profile_id, + ) + ) + item = result.scalar_one_or_none() + if item is None: + return False + await db_session.delete(item) + return True + + +async def delete_job_by_id( + db_session: AsyncSession, + *, + job_id: int, +) -> bool: + result = await db_session.execute( + select(IngestionQueueItem).where(IngestionQueueItem.id == job_id) + ) + item = result.scalar_one_or_none() + if item is None: + return False + await db_session.delete(item) + return True + + +async def list_due_jobs( + db_session: AsyncSession, + *, + now: datetime, + limit: int, +) -> list[ContinuationQueueJob]: + result = await db_session.execute( + select(IngestionQueueItem) + .where( + IngestionQueueItem.next_attempt_dt <= now, + IngestionQueueItem.status.in_(ACTIVE_QUEUE_STATUSES), + ) + .order_by( + IngestionQueueItem.next_attempt_dt.asc(), + IngestionQueueItem.id.asc(), + ) + .limit(limit) + ) + rows = list(result.scalars().all()) + jobs: list[ContinuationQueueJob] = [] + for row in rows: + jobs.append( + ContinuationQueueJob( + id=int(row.id), + user_id=int(row.user_id), + scholar_profile_id=int(row.scholar_profile_id), + resume_cstart=normalize_cstart(row.resume_cstart), + reason=row.reason, + status=row.status, + attempt_count=int(row.attempt_count), + next_attempt_dt=row.next_attempt_dt, + ) + ) + return jobs + + +async def increment_attempt_count( + db_session: AsyncSession, + *, + job_id: int, +) -> IngestionQueueItem | None: + now = datetime.now(timezone.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 + item.attempt_count = int(item.attempt_count or 0) + 1 + item.updated_at = now + return item + + +async def reset_attempt_count( + db_session: AsyncSession, + *, + job_id: int, +) -> IngestionQueueItem | None: + now = datetime.now(timezone.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 + item.attempt_count = 0 + item.updated_at = now + return item + + +async def reschedule_job( + db_session: AsyncSession, + *, + job_id: int, + delay_seconds: int, + 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) + ) + item = result.scalar_one_or_none() + if item is None: + return None + item.next_attempt_dt = now + timedelta(seconds=max(1, int(delay_seconds))) + item.status = QueueItemStatus.QUEUED.value + item.reason = reason + item.last_error = error + item.dropped_reason = None + item.dropped_at = None + item.updated_at = now + return item + + +async def mark_retrying( + db_session: AsyncSession, + *, + 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) + ) + item = result.scalar_one_or_none() + if item is None: + return None + if item.status == QueueItemStatus.DROPPED.value: + return item + item.status = QueueItemStatus.RETRYING.value + if reason: + item.reason = reason + item.updated_at = now + return item + + +async def mark_dropped( + db_session: AsyncSession, + *, + job_id: int, + 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) + ) + item = result.scalar_one_or_none() + if item is None: + return None + item.status = QueueItemStatus.DROPPED.value + item.reason = "dropped" + item.dropped_reason = reason + item.dropped_at = now + if error is not None: + item.last_error = error + item.updated_at = now + return item + + +async def mark_queued_now( + db_session: AsyncSession, + *, + job_id: int, + 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) + ) + item = result.scalar_one_or_none() + if item is None: + return None + item.status = QueueItemStatus.QUEUED.value + item.reason = reason + item.next_attempt_dt = now + if reset_attempt_count: + item.attempt_count = 0 + item.last_error = None + item.dropped_reason = None + item.dropped_at = None + item.updated_at = now + return item diff --git a/build/lib/app/services/domains/ingestion/safety.py b/build/lib/app/services/domains/ingestion/safety.py new file mode 100644 index 0000000..debb8c5 --- /dev/null +++ b/build/lib/app/services/domains/ingestion/safety.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +from app.db.models import UserSetting + +COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD = "blocked_failure_threshold_exceeded" +COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD = "network_failure_threshold_exceeded" + +_COUNTER_CONSECUTIVE_BLOCKED_RUNS = "consecutive_blocked_runs" +_COUNTER_CONSECUTIVE_NETWORK_RUNS = "consecutive_network_runs" +_COUNTER_COOLDOWN_ENTRY_COUNT = "cooldown_entry_count" +_COUNTER_BLOCKED_START_COUNT = "blocked_start_count" +_COUNTER_LAST_BLOCKED_FAILURE_COUNT = "last_blocked_failure_count" +_COUNTER_LAST_NETWORK_FAILURE_COUNT = "last_network_failure_count" +_COUNTER_LAST_EVALUATED_RUN_ID = "last_evaluated_run_id" + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _safe_optional_int(value: Any) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +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) + + +def _state_dict(settings: UserSetting) -> dict[str, Any]: + state = settings.scrape_safety_state + if isinstance(state, dict): + return state + return {} + + +def _counters_from_state(settings: UserSetting) -> dict[str, Any]: + state = _state_dict(settings) + return { + _COUNTER_CONSECUTIVE_BLOCKED_RUNS: max( + 0, + _safe_int(state.get(_COUNTER_CONSECUTIVE_BLOCKED_RUNS), 0), + ), + _COUNTER_CONSECUTIVE_NETWORK_RUNS: max( + 0, + _safe_int(state.get(_COUNTER_CONSECUTIVE_NETWORK_RUNS), 0), + ), + _COUNTER_COOLDOWN_ENTRY_COUNT: max( + 0, + _safe_int(state.get(_COUNTER_COOLDOWN_ENTRY_COUNT), 0), + ), + _COUNTER_BLOCKED_START_COUNT: max( + 0, + _safe_int(state.get(_COUNTER_BLOCKED_START_COUNT), 0), + ), + _COUNTER_LAST_BLOCKED_FAILURE_COUNT: max( + 0, + _safe_int(state.get(_COUNTER_LAST_BLOCKED_FAILURE_COUNT), 0), + ), + _COUNTER_LAST_NETWORK_FAILURE_COUNT: max( + 0, + _safe_int(state.get(_COUNTER_LAST_NETWORK_FAILURE_COUNT), 0), + ), + _COUNTER_LAST_EVALUATED_RUN_ID: _safe_optional_int( + state.get(_COUNTER_LAST_EVALUATED_RUN_ID), + ), + } + + +def _cooldown_reason_label(reason: str | None) -> str | None: + if reason == COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD: + return "Blocked responses exceeded safety threshold" + if reason == COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD: + return "Network failures exceeded safety threshold" + return None + + +def _recommended_action(reason: str | None) -> str | None: + if reason == COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD: + return ( + "Google Scholar appears to be blocking requests. Wait for cooldown to expire, " + "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 None + + +def is_cooldown_active( + settings: UserSetting, + *, + now_utc: datetime | None = None, +) -> bool: + now = now_utc or _utcnow() + cooldown_until = _normalize_datetime(settings.scrape_cooldown_until) + if cooldown_until is None: + return False + return cooldown_until > now + + +def clear_expired_cooldown( + settings: UserSetting, + *, + now_utc: datetime | None = None, +) -> bool: + now = now_utc or _utcnow() + cooldown_until = _normalize_datetime(settings.scrape_cooldown_until) + if cooldown_until is None: + return False + if cooldown_until > now: + return False + settings.scrape_cooldown_until = None + settings.scrape_cooldown_reason = None + return True + + +def register_cooldown_blocked_start( + settings: UserSetting, + *, + now_utc: datetime | None = None, +) -> dict[str, Any]: + now = now_utc or _utcnow() + counters = _counters_from_state(settings) + counters[_COUNTER_BLOCKED_START_COUNT] = int(counters[_COUNTER_BLOCKED_START_COUNT]) + 1 + settings.scrape_safety_state = counters + return get_safety_state_payload(settings, now_utc=now) + + +def _update_run_counters( + *, + counters: dict[str, Any], + run_id: int, + blocked_failure_count: int, + network_failure_count: int, +) -> tuple[int, int]: + bounded_blocked_failures = max(0, int(blocked_failure_count)) + bounded_network_failures = max(0, int(network_failure_count)) + counters[_COUNTER_LAST_BLOCKED_FAILURE_COUNT] = bounded_blocked_failures + 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 + ) + counters[_COUNTER_CONSECUTIVE_NETWORK_RUNS] = ( + int(counters[_COUNTER_CONSECUTIVE_NETWORK_RUNS]) + 1 + if bounded_network_failures > 0 + else 0 + ) + return bounded_blocked_failures, bounded_network_failures + + +def _resolve_cooldown_trigger( + *, + blocked_failures: int, + network_failures: int, + blocked_failure_threshold: int, + network_failure_threshold: int, + blocked_cooldown_seconds: int, + network_cooldown_seconds: int, +) -> tuple[str | None, int]: + if blocked_failures >= max(1, int(blocked_failure_threshold)): + return COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD, max(60, int(blocked_cooldown_seconds)) + if network_failures >= max(1, int(network_failure_threshold)): + return COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD, max(60, int(network_cooldown_seconds)) + return None, 0 + + +def _apply_cooldown_decision( + *, + settings: UserSetting, + counters: dict[str, Any], + now: datetime, + reason: str | None, + cooldown_seconds: int, +) -> None: + if reason is None: + clear_expired_cooldown(settings, now_utc=now) + return + settings.scrape_cooldown_reason = reason + settings.scrape_cooldown_until = now + timedelta(seconds=max(60, int(cooldown_seconds))) + counters[_COUNTER_COOLDOWN_ENTRY_COUNT] = int(counters[_COUNTER_COOLDOWN_ENTRY_COUNT]) + 1 + + +def apply_run_safety_outcome( + settings: UserSetting, + *, + run_id: int, + blocked_failure_count: int, + network_failure_count: int, + blocked_failure_threshold: int, + network_failure_threshold: int, + blocked_cooldown_seconds: int, + network_cooldown_seconds: int, + now_utc: datetime | None = None, +) -> tuple[dict[str, Any], str | None]: + now = now_utc or _utcnow() + counters = _counters_from_state(settings) + blocked_failures, network_failures = _update_run_counters( + counters=counters, + run_id=run_id, + blocked_failure_count=blocked_failure_count, + network_failure_count=network_failure_count, + ) + reason, cooldown_seconds = _resolve_cooldown_trigger( + blocked_failures=blocked_failures, + network_failures=network_failures, + blocked_failure_threshold=blocked_failure_threshold, + network_failure_threshold=network_failure_threshold, + blocked_cooldown_seconds=blocked_cooldown_seconds, + network_cooldown_seconds=network_cooldown_seconds, + ) + _apply_cooldown_decision( + settings=settings, + counters=counters, + now=now, + reason=reason, + cooldown_seconds=cooldown_seconds, + ) + settings.scrape_safety_state = counters + return get_safety_state_payload(settings, now_utc=now), reason + + +def get_safety_state_payload( + settings: UserSetting, + *, + now_utc: datetime | None = None, +) -> dict[str, Any]: + now = now_utc or _utcnow() + cooldown_until = _normalize_datetime(settings.scrape_cooldown_until) + cooldown_active = bool(cooldown_until is not None and cooldown_until > now) + cooldown_remaining_seconds = 0 + if cooldown_active and cooldown_until is not None: + cooldown_remaining_seconds = max(0, int((cooldown_until - now).total_seconds())) + + reason = settings.scrape_cooldown_reason if cooldown_active else None + + return { + "cooldown_active": cooldown_active, + "cooldown_reason": reason, + "cooldown_reason_label": _cooldown_reason_label(reason), + "cooldown_until": cooldown_until, + "cooldown_remaining_seconds": cooldown_remaining_seconds, + "recommended_action": _recommended_action(reason), + "counters": _counters_from_state(settings), + } + + +def get_safety_event_context( + settings: UserSetting, + *, + now_utc: datetime | None = None, +) -> dict[str, Any]: + payload = get_safety_state_payload(settings, now_utc=now_utc) + return { + "cooldown_active": bool(payload.get("cooldown_active")), + "cooldown_reason": payload.get("cooldown_reason"), + "cooldown_until": payload.get("cooldown_until"), + "cooldown_remaining_seconds": int(payload.get("cooldown_remaining_seconds") or 0), + "safety_counters": payload.get("counters", {}), + } diff --git a/build/lib/app/services/domains/ingestion/scheduler.py b/build/lib/app/services/domains/ingestion/scheduler.py new file mode 100644 index 0000000..b05e84d --- /dev/null +++ b/build/lib/app/services/domains/ingestion/scheduler.py @@ -0,0 +1,622 @@ +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() + + await self._drain_pdf_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, + 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 + + @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 _drain_pdf_queue(self) -> None: + from app.services.domains.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: + logger.info("scheduler.pdf_queue_drain_completed", extra={ + "event": "scheduler.pdf_queue_drain_completed", + "processed_count": processed, + }) + except Exception: + logger.exception("scheduler.pdf_queue_drain_failed", extra={ + "event": "scheduler.pdf_queue_drain_failed", + }) + + 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/build/lib/app/services/domains/ingestion/types.py b/build/lib/app/services/domains/ingestion/types.py new file mode 100644 index 0000000..09d87f1 --- /dev/null +++ b/build/lib/app/services/domains/ingestion/types.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +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 + + +@dataclass(frozen=True) +class RunExecutionSummary: + crawl_run_id: int + status: RunStatus + scholar_count: int + succeeded_count: int + failed_count: int + partial_count: int + new_publication_count: int + + +@dataclass(frozen=True) +class PagedParseResult: + fetch_result: FetchResult + parsed_page: ParsedProfilePage + first_page_fetch_result: FetchResult + first_page_parsed_page: ParsedProfilePage + first_page_fingerprint_sha256: str | None + publications: list[PublicationCandidate] + attempt_log: list[dict[str, Any]] + page_logs: list[dict[str, Any]] + pages_fetched: int + pages_attempted: int + has_more_remaining: bool + pagination_truncated_reason: str | None + continuation_cstart: int | None + skipped_no_change: bool + discovered_publication_count: int + + +@dataclass +class RunProgress: + succeeded_count: int = 0 + failed_count: int = 0 + partial_count: int = 0 + scholar_results: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass(frozen=True) +class ScholarProcessingOutcome: + result_entry: dict[str, Any] + succeeded_count_delta: int + failed_count_delta: int + partial_count_delta: int + discovered_publication_count: int + + +@dataclass(frozen=True) +class RunFailureSummary: + failed_state_counts: dict[str, int] + failed_reason_counts: dict[str, int] + scrape_failure_counts: dict[str, int] + retries_scheduled_count: int + scholars_with_retries_count: int + retry_exhausted_count: int + + +@dataclass(frozen=True) +class RunAlertSummary: + blocked_failure_count: int + network_failure_count: int + blocked_failure_threshold: int + network_failure_threshold: int + retry_scheduled_threshold: int + alert_flags: dict[str, bool] + + +@dataclass +class PagedLoopState: + fetch_result: FetchResult + parsed_page: ParsedProfilePage + attempt_log: list[dict[str, Any]] + page_logs: list[dict[str, Any]] + publications: list[PublicationCandidate] + pages_fetched: int + pages_attempted: int + current_cstart: int + next_cstart: int + has_more_remaining: bool = False + pagination_truncated_reason: str | None = None + continuation_cstart: int | None = None + discovered_publication_count: int = 0 + + +class RunAlreadyInProgressError(RuntimeError): + """Raised when a run lock for a user is already held by another process.""" + + +class RunBlockedBySafetyPolicyError(RuntimeError): + def __init__( + self, + *, + code: str, + message: str, + safety_state: dict[str, Any], + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.safety_state = safety_state diff --git a/build/lib/app/services/domains/openalex/__init__.py b/build/lib/app/services/domains/openalex/__init__.py new file mode 100644 index 0000000..a51555a --- /dev/null +++ b/build/lib/app/services/domains/openalex/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/build/lib/app/services/domains/openalex/client.py b/build/lib/app/services/domains/openalex/client.py new file mode 100644 index 0000000..c62d876 --- /dev/null +++ b/build/lib/app/services/domains/openalex/client.py @@ -0,0 +1,135 @@ +import asyncio +import logging +from typing import Any, Mapping + +import httpx +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from app.services.domains.openalex.types import OpenAlexWork + +logger = logging.getLogger(__name__) + +OPENALEX_BASE_URL = "https://api.openalex.org" + + +class OpenAlexClientError(Exception): + pass + + +class OpenAlexRateLimitError(OpenAlexClientError): + 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, OpenAlexRateLimitError)), + 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: + raise OpenAlexRateLimitError("Rate limit exceeded fetching OpenAlex work by DOI") + if response.status_code >= 400: + logger.warning("OpenAlex API error: %s %s", response.status_code, response.text) + 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, OpenAlexRateLimitError)), + 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: + raise OpenAlexRateLimitError("Rate limit exceeded fetching OpenAlex works list") + if response.status_code >= 400: + logger.warning("OpenAlex API error (filters=%s): %s %s", filters, response.status_code, response.text) + 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 e: + logger.warning("Failed to parse OpenAlex raw dict: %s", e) + continue + + return parsed_works diff --git a/build/lib/app/services/domains/openalex/matching.py b/build/lib/app/services/domains/openalex/matching.py new file mode 100644 index 0000000..cfa511c --- /dev/null +++ b/build/lib/app/services/domains/openalex/matching.py @@ -0,0 +1,108 @@ +import logging +import re + +from rapidfuzz import fuzz + +from app.services.domains.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: + if 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/build/lib/app/services/domains/openalex/types.py b/build/lib/app/services/domains/openalex/types.py new file mode 100644 index 0000000..0d86754 --- /dev/null +++ b/build/lib/app/services/domains/openalex/types.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Mapping + + +@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/build/lib/app/services/domains/portability/__init__.py b/build/lib/app/services/domains/portability/__init__.py new file mode 100644 index 0000000..28aae8b --- /dev/null +++ b/build/lib/app/services/domains/portability/__init__.py @@ -0,0 +1 @@ +from app.services.domains.portability.application import * diff --git a/build/lib/app/services/domains/portability/application.py b/build/lib/app/services/domains/portability/application.py new file mode 100644 index 0000000..7e0b56e --- /dev/null +++ b/build/lib/app/services/domains/portability/application.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Publication +from app.services.domains.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 ( + _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 + + +async def import_user_data( + db_session: AsyncSession, + *, + user_id: int, + scholars: list[dict[str, Any]], + publications: list[dict[str, Any]], +) -> dict[str, int]: + _validate_import_sizes(scholars=scholars, publications=publications) + scholar_map, counters = await _upsert_imported_scholars( + db_session, + user_id=user_id, + scholars=scholars, + ) + cluster_cache: dict[str, Publication | None] = {} + fingerprint_cache: dict[str, Publication | None] = {} + _initialize_import_counters(counters) + for item in publications: + parsed_item = _build_imported_publication_input( + item=item, + scholar_map=scholar_map, + ) + if parsed_item is None: + counters["skipped_records"] += 1 + continue + await _upsert_imported_publication( + db_session, + payload=parsed_item, + cluster_cache=cluster_cache, + fingerprint_cache=fingerprint_cache, + counters=counters, + ) + await db_session.commit() + return counters + + +__all__ = [ + "EXPORT_SCHEMA_VERSION", + "MAX_IMPORT_SCHOLARS", + "MAX_IMPORT_PUBLICATIONS", + "ImportExportError", + "ImportedPublicationInput", + "export_user_data", + "import_user_data", +] diff --git a/build/lib/app/services/domains/portability/constants.py b/build/lib/app/services/domains/portability/constants.py new file mode 100644 index 0000000..6815347 --- /dev/null +++ b/build/lib/app/services/domains/portability/constants.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import re + +EXPORT_SCHEMA_VERSION = 1 +MAX_IMPORT_SCHOLARS = 10_000 +MAX_IMPORT_PUBLICATIONS = 100_000 +WORD_RE = re.compile(r"[a-z0-9]+") +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") diff --git a/build/lib/app/services/domains/portability/exporting.py b/build/lib/app/services/domains/portability/exporting.py new file mode 100644 index 0000000..5c89006 --- /dev/null +++ b/build/lib/app/services/domains/portability/exporting.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from datetime import datetime, timezone +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 + + +def _exported_at_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def _serialize_export_scholar(profile: ScholarProfile) -> dict[str, Any]: + return { + "scholar_id": profile.scholar_id, + "display_name": profile.display_name, + "is_enabled": bool(profile.is_enabled), + "profile_image_override_url": profile.profile_image_override_url, + } + + +def _serialize_export_publication(row: tuple[Any, ...]) -> dict[str, Any]: + ( + scholar_id, + cluster_id, + fingerprint_sha256, + title_raw, + year, + citation_count, + author_text, + venue_text, + pub_url, + pdf_url, + is_read, + ) = row + return { + "scholar_id": scholar_id, + "cluster_id": cluster_id, + "fingerprint_sha256": fingerprint_sha256, + "title": title_raw, + "year": year, + "citation_count": int(citation_count or 0), + "author_text": author_text, + "venue_text": venue_text, + "pub_url": pub_url, + "pdf_url": pdf_url, + "is_read": bool(is_read), + } + + +async def export_user_data( + db_session: AsyncSession, + *, + 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()) + ) + publication_result = await db_session.execute( + select( + ScholarProfile.scholar_id, + Publication.cluster_id, + Publication.fingerprint_sha256, + Publication.title_raw, + Publication.year, + Publication.citation_count, + Publication.author_text, + Publication.venue_text, + Publication.pub_url, + Publication.pdf_url, + ScholarPublication.is_read, + ) + .join(ScholarPublication, ScholarPublication.scholar_profile_id == ScholarProfile.id) + .join(Publication, Publication.id == ScholarPublication.publication_id) + .where(ScholarProfile.user_id == user_id) + .order_by(ScholarPublication.created_at.desc(), Publication.id.desc()) + ) + scholars = [_serialize_export_scholar(profile) for profile in scholars_result.scalars().all()] + publications = [_serialize_export_publication(row) for row in publication_result.all()] + return { + "schema_version": EXPORT_SCHEMA_VERSION, + "exported_at": _exported_at_iso(), + "scholars": scholars, + "publications": publications, + } diff --git a/build/lib/app/services/domains/portability/normalize.py b/build/lib/app/services/domains/portability/normalize.py new file mode 100644 index 0000000..60dd619 --- /dev/null +++ b/build/lib/app/services/domains/portability/normalize.py @@ -0,0 +1,109 @@ +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 ( + MAX_IMPORT_PUBLICATIONS, + MAX_IMPORT_SCHOLARS, + SHA256_RE, + WORD_RE, +) +from app.services.domains.portability.types import ImportExportError + + +def _normalize_optional_text(value: Any) -> str | None: + if value is None: + return None + normalized = str(value).strip() + return normalized or None + + +def _normalize_optional_year(value: Any) -> int | None: + if value is None: + return None + try: + year = int(value) + except (TypeError, ValueError): + return None + if year < 1500 or year > 3000: + return None + return year + + +def _normalize_citation_count(value: Any) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return 0 + return max(0, parsed) + + +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_fingerprint( + *, + title: str, + year: int | None, + author_text: str | None, + venue_text: str | None, +) -> str: + canonical = "|".join( + [ + normalize_title(title), + str(year) if year is not None else "", + _first_author_last_name(author_text), + _first_venue_word(venue_text), + ] + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _resolve_fingerprint( + *, + title: str, + year: int | None, + author_text: str | None, + venue_text: str | None, + provided_fingerprint: Any, +) -> str: + normalized = _normalize_optional_text(provided_fingerprint) + if normalized and SHA256_RE.fullmatch(normalized.lower()): + return normalized.lower() + return _build_fingerprint( + title=title, + year=year, + author_text=author_text, + venue_text=venue_text, + ) + + +def _validate_import_sizes( + *, + scholars: list[dict[str, Any]], + publications: list[dict[str, Any]], +) -> None: + 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})." + ) diff --git a/build/lib/app/services/domains/portability/publication_import.py b/build/lib/app/services/domains/portability/publication_import.py new file mode 100644 index 0000000..7654586 --- /dev/null +++ b/build/lib/app/services/domains/portability/publication_import.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +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.ingestion.application import build_publication_url, normalize_title +from app.services.domains.doi.normalize import normalize_doi +from app.services.domains.portability.normalize import ( + _normalize_citation_count, + _normalize_optional_text, + _normalize_optional_year, + _resolve_fingerprint, +) +from app.services.domains.portability.types import ImportedPublicationInput + + +async def _find_publication_by_cluster( + db_session: AsyncSession, + *, + cluster_id: str, +) -> Publication | 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_sha256: str, +) -> Publication | None: + result = await db_session.execute( + select(Publication).where(Publication.fingerprint_sha256 == fingerprint_sha256) + ) + return result.scalar_one_or_none() + + +async def _find_linked_publication_by_title( + db_session: AsyncSession, + *, + scholar_profile_id: int, + title: str, +) -> Publication | None: + normalized_title = normalize_title(title) + result = await db_session.execute( + select(Publication) + .join( + ScholarPublication, + ScholarPublication.publication_id == Publication.id, + ) + .where( + ScholarPublication.scholar_profile_id == scholar_profile_id, + Publication.title_normalized == normalized_title, + ) + .order_by(Publication.id.asc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +def _apply_imported_publication_values( + *, + publication: Publication, + title: str, + year: int | None, + citation_count: int, + author_text: str | None, + venue_text: str | None, + pub_url: str | None, + pdf_url: str | None, + cluster_id: str | None, +) -> bool: + updated = False + if cluster_id and publication.cluster_id != cluster_id: + publication.cluster_id = cluster_id + updated = True + if publication.title_raw != title: + publication.title_raw = title + publication.title_normalized = normalize_title(title) + updated = True + if publication.year != year: + publication.year = year + updated = True + if int(publication.citation_count or 0) != citation_count: + publication.citation_count = citation_count + updated = True + if publication.author_text != author_text: + publication.author_text = author_text + updated = True + if publication.venue_text != venue_text: + publication.venue_text = venue_text + updated = True + if pub_url and publication.pub_url != pub_url: + publication.pub_url = pub_url + updated = True + if pdf_url and publication.pdf_url != pdf_url: + publication.pdf_url = pdf_url + updated = True + return updated + + +def _new_publication( + *, + cluster_id: str | None, + fingerprint_sha256: str, + title: str, + year: int | None, + citation_count: int, + author_text: str | None, + venue_text: str | None, + pub_url: str | None, + pdf_url: str | None, +) -> Publication: + return Publication( + cluster_id=cluster_id, + fingerprint_sha256=fingerprint_sha256, + title_raw=title, + title_normalized=normalize_title(title), + year=year, + citation_count=citation_count, + author_text=author_text, + venue_text=venue_text, + pub_url=pub_url, + pdf_url=pdf_url, + ) + + +async def _resolve_publication_for_import( + db_session: AsyncSession, + *, + scholar_profile_id: int, + title: str, + cluster_id: str | None, + fingerprint_sha256: str, + cluster_cache: dict[str, Publication | None], + fingerprint_cache: dict[str, Publication | None], +) -> Publication | None: + if cluster_id: + if cluster_id not in cluster_cache: + cluster_cache[cluster_id] = await _find_publication_by_cluster( + db_session, + cluster_id=cluster_id, + ) + if cluster_cache[cluster_id] is not None: + return cluster_cache[cluster_id] + if fingerprint_sha256 not in fingerprint_cache: + fingerprint_cache[fingerprint_sha256] = await _find_publication_by_fingerprint( + db_session, + fingerprint_sha256=fingerprint_sha256, + ) + if fingerprint_cache[fingerprint_sha256] is not None: + return fingerprint_cache[fingerprint_sha256] + return await _find_linked_publication_by_title( + db_session, + scholar_profile_id=scholar_profile_id, + title=title, + ) + + +async def _upsert_scholar_publication_link( + db_session: AsyncSession, + *, + scholar_profile_id: int, + publication_id: int, + is_read: bool, +) -> tuple[bool, bool]: + result = await db_session.execute( + select(ScholarPublication).where( + ScholarPublication.scholar_profile_id == scholar_profile_id, + ScholarPublication.publication_id == publication_id, + ) + ) + link = result.scalar_one_or_none() + if link is None: + db_session.add( + ScholarPublication( + scholar_profile_id=scholar_profile_id, + publication_id=publication_id, + is_read=bool(is_read), + ) + ) + return True, False + if bool(link.is_read) == bool(is_read): + return False, False + link.is_read = bool(is_read) + return False, True + + +def _initialize_import_counters(counters: dict[str, int]) -> None: + counters.update( + { + "publications_created": 0, + "publications_updated": 0, + "links_created": 0, + "links_updated": 0, + } + ) + + +def _build_imported_publication_input( + *, + item: dict[str, Any], + scholar_map: dict[str, ScholarProfile], +) -> ImportedPublicationInput | None: + scholar_id = _normalize_optional_text(item.get("scholar_id")) + title = _normalize_optional_text(item.get("title")) + if not scholar_id or not title: + return None + + profile = scholar_map.get(scholar_id) + if profile is None: + return None + + year = _normalize_optional_year(item.get("year")) + author_text = _normalize_optional_text(item.get("author_text")) + venue_text = _normalize_optional_text(item.get("venue_text")) + return ImportedPublicationInput( + profile=profile, + title=title, + year=year, + citation_count=_normalize_citation_count(item.get("citation_count")), + author_text=author_text, + venue_text=venue_text, + cluster_id=_normalize_optional_text(item.get("cluster_id")), + pub_url=build_publication_url(_normalize_optional_text(item.get("pub_url"))), + doi=normalize_doi(_normalize_optional_text(item.get("doi"))), + pdf_url=build_publication_url(_normalize_optional_text(item.get("pdf_url"))), + fingerprint=_resolve_fingerprint( + title=title, + year=year, + author_text=author_text, + venue_text=venue_text, + provided_fingerprint=item.get("fingerprint_sha256"), + ), + is_read=bool(item.get("is_read", False)), + ) + + +def _update_link_counters( + *, + counters: dict[str, int], + link_created: bool, + link_updated: bool, +) -> None: + if link_created: + counters["links_created"] += 1 + if link_updated: + counters["links_updated"] += 1 + + +def _cache_resolved_publication( + *, + publication: Publication, + cluster_id: str | None, + fingerprint_sha256: str, + cluster_cache: dict[str, Publication | None], + fingerprint_cache: dict[str, Publication | None], +) -> None: + if cluster_id: + cluster_cache[cluster_id] = publication + fingerprint_cache[fingerprint_sha256] = publication + + +async def _create_import_publication( + db_session: AsyncSession, + *, + payload: ImportedPublicationInput, +) -> Publication: + publication = _new_publication( + cluster_id=payload.cluster_id, + fingerprint_sha256=payload.fingerprint, + title=payload.title, + year=payload.year, + citation_count=payload.citation_count, + author_text=payload.author_text, + venue_text=payload.venue_text, + pub_url=payload.pub_url, + pdf_url=payload.pdf_url, + ) + db_session.add(publication) + await db_session.flush() + return publication + + +def _update_import_publication( + *, + publication: Publication, + payload: ImportedPublicationInput, +) -> bool: + return _apply_imported_publication_values( + publication=publication, + title=payload.title, + year=payload.year, + citation_count=payload.citation_count, + author_text=payload.author_text, + venue_text=payload.venue_text, + pub_url=payload.pub_url, + pdf_url=payload.pdf_url, + cluster_id=payload.cluster_id, + ) + + +async def _upsert_publication_entity( + db_session: AsyncSession, + *, + payload: ImportedPublicationInput, + cluster_cache: dict[str, Publication | None], + fingerprint_cache: dict[str, Publication | None], +) -> tuple[Publication, bool, bool]: + publication = await _resolve_publication_for_import( + db_session, + scholar_profile_id=int(payload.profile.id), + title=payload.title, + cluster_id=payload.cluster_id, + fingerprint_sha256=payload.fingerprint, + cluster_cache=cluster_cache, + fingerprint_cache=fingerprint_cache, + ) + created = False + updated = False + if publication is None: + publication = await _create_import_publication(db_session, payload=payload) + created = True + else: + updated = _update_import_publication(publication=publication, payload=payload) + + _cache_resolved_publication( + publication=publication, + cluster_id=payload.cluster_id, + fingerprint_sha256=payload.fingerprint, + cluster_cache=cluster_cache, + fingerprint_cache=fingerprint_cache, + ) + return publication, created, updated + + +async def _upsert_imported_publication( + db_session: AsyncSession, + *, + payload: ImportedPublicationInput, + cluster_cache: dict[str, Publication | None], + fingerprint_cache: dict[str, Publication | None], + counters: dict[str, int], +) -> None: + publication, created, updated = await _upsert_publication_entity( + db_session, + payload=payload, + cluster_cache=cluster_cache, + fingerprint_cache=fingerprint_cache, + ) + if created: + counters["publications_created"] += 1 + if updated: + counters["publications_updated"] += 1 + + link_created, link_updated = await _upsert_scholar_publication_link( + db_session, + scholar_profile_id=int(payload.profile.id), + publication_id=int(publication.id), + is_read=payload.is_read, + ) + _update_link_counters( + counters=counters, + link_created=link_created, + link_updated=link_updated, + ) diff --git a/build/lib/app/services/domains/portability/scholar_import.py b/build/lib/app/services/domains/portability/scholar_import.py new file mode 100644 index 0000000..74e6ec1 --- /dev/null +++ b/build/lib/app/services/domains/portability/scholar_import.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from typing import Any + +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 + + +async def _load_user_scholar_map( + db_session: AsyncSession, + *, + user_id: int, +) -> dict[str, ScholarProfile]: + 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} + + +def _apply_imported_scholar_values( + *, + profile: ScholarProfile, + display_name: str | None, + profile_image_override_url: str | None, + is_enabled: bool, +) -> bool: + updated = False + if display_name and profile.display_name != display_name: + profile.display_name = display_name + updated = True + if profile.profile_image_override_url != profile_image_override_url: + profile.profile_image_override_url = profile_image_override_url + updated = True + if bool(profile.is_enabled) != bool(is_enabled): + profile.is_enabled = bool(is_enabled) + updated = True + return updated + + +def _new_scholar_profile( + *, + user_id: int, + scholar_id: str, + display_name: str | None, + profile_image_override_url: str | None, + is_enabled: bool, +) -> ScholarProfile: + return ScholarProfile( + user_id=user_id, + scholar_id=scholar_id, + display_name=display_name, + profile_image_override_url=profile_image_override_url, + is_enabled=bool(is_enabled), + ) + + +async def _upsert_imported_scholars( + db_session: AsyncSession, + *, + user_id: int, + scholars: list[dict[str, Any]], +) -> tuple[dict[str, ScholarProfile], dict[str, int]]: + scholar_map = await _load_user_scholar_map(db_session, user_id=user_id) + counters = {"scholars_created": 0, "scholars_updated": 0, "skipped_records": 0} + 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 "") + ) + override_url = scholar_service.normalize_profile_image_url( + _normalize_optional_text(item.get("profile_image_override_url")) + ) + except (KeyError, scholar_service.ScholarServiceError): + counters["skipped_records"] += 1 + continue + + is_enabled = bool(item.get("is_enabled", True)) + existing = scholar_map.get(scholar_id) + if existing is None: + profile = _new_scholar_profile( + user_id=user_id, + scholar_id=scholar_id, + display_name=display_name, + profile_image_override_url=override_url, + is_enabled=is_enabled, + ) + db_session.add(profile) + scholar_map[scholar_id] = profile + counters["scholars_created"] += 1 + continue + + if _apply_imported_scholar_values( + profile=existing, + display_name=display_name, + profile_image_override_url=override_url, + is_enabled=is_enabled, + ): + counters["scholars_updated"] += 1 + + await db_session.flush() + return scholar_map, counters diff --git a/build/lib/app/services/domains/portability/types.py b/build/lib/app/services/domains/portability/types.py new file mode 100644 index 0000000..2a7f6e8 --- /dev/null +++ b/build/lib/app/services/domains/portability/types.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from app.db.models import ScholarProfile + + +class ImportExportError(ValueError): + """Raised when import/export payload constraints are violated.""" + + +@dataclass(frozen=True) +class ImportedPublicationInput: + profile: ScholarProfile + title: str + year: int | None + citation_count: int + author_text: str | None + venue_text: str | None + cluster_id: str | None + pub_url: str | None + doi: str | None + pdf_url: str | None + fingerprint: str + is_read: bool diff --git a/build/lib/app/services/domains/publication_identifiers/__init__.py b/build/lib/app/services/domains/publication_identifiers/__init__.py new file mode 100644 index 0000000..3a35c66 --- /dev/null +++ b/build/lib/app/services/domains/publication_identifiers/__init__.py @@ -0,0 +1,17 @@ +from app.services.domains.publication_identifiers.application import ( + DisplayIdentifier, + derive_display_identifier_from_values, + 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", + "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/build/lib/app/services/domains/publication_identifiers/application.py b/build/lib/app/services/domains/publication_identifiers/application.py new file mode 100644 index 0000000..ca57d57 --- /dev/null +++ b/build/lib/app/services/domains/publication_identifiers/application.py @@ -0,0 +1,433 @@ +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.domains.doi.normalize import normalize_doi +from app.services.domains.publication_identifiers.normalize import ( + normalize_arxiv_id, + normalize_pmcid, + normalize_pmid, +) +from app.services.domains.publication_identifiers.types import ( + DisplayIdentifier, + IdentifierCandidate, + IdentifierKind, +) + +if TYPE_CHECKING: + from app.services.domains.publications.pdf_queue import PdfQueueListItem + from app.services.domains.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) + + existing_doi = await _existing_identifier_by_kind( + db_session, + publication_id=int(publication.id), + kind=IdentifierKind.DOI.value, + ) + if existing_doi is not None: + return + + from app.services.domains.crossref import application as crossref_service + from app.services.domains.publications.types import UnreadPublicationItem + + item = 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, + ) + + discovered_doi = await crossref_service.discover_doi_for_publication(item=item) + if discovered_doi: + normalized_doi = normalize_doi(discovered_doi) + if normalized_doi: + candidate = _candidate( + IdentifierKind.DOI, + discovered_doi, + normalized_doi, + "crossref_api", + CONFIDENCE_MEDIUM, + None, + ) + await _upsert_publication_candidate(db_session, publication_id=int(publication.id), candidate=candidate) + + existing_arxiv = await _existing_identifier_by_kind( + db_session, + publication_id=int(publication.id), + kind=IdentifierKind.ARXIV.value, + ) + if existing_arxiv is None: + from app.services.domains.arxiv import application as arxiv_service + discovered_arxiv = await arxiv_service.discover_arxiv_id_for_publication(item=item) + if discovered_arxiv: + normalized_arxiv = normalize_arxiv_id(discovered_arxiv) + if normalized_arxiv: + candidate = _candidate( + IdentifierKind.ARXIV, + discovered_arxiv, + normalized_arxiv, + "arxiv_api", + CONFIDENCE_MEDIUM, + None, + ) + await _upsert_publication_candidate(db_session, publication_id=int(publication.id), candidate=candidate) + + +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_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/build/lib/app/services/domains/publication_identifiers/normalize.py b/build/lib/app/services/domains/publication_identifiers/normalize.py new file mode 100644 index 0000000..9f7484a --- /dev/null +++ b/build/lib/app/services/domains/publication_identifiers/normalize.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import re +from urllib.parse import urlparse + +from app.services.domains.doi.normalize import normalize_doi +from app.services.domains.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_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) + 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/build/lib/app/services/domains/publication_identifiers/types.py b/build/lib/app/services/domains/publication_identifiers/types.py new file mode 100644 index 0000000..34b20d0 --- /dev/null +++ b/build/lib/app/services/domains/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/build/lib/app/services/domains/publications/__init__.py b/build/lib/app/services/domains/publications/__init__.py new file mode 100644 index 0000000..371b02c --- /dev/null +++ b/build/lib/app/services/domains/publications/__init__.py @@ -0,0 +1 @@ +from app.services.domains.publications.application import * diff --git a/build/lib/app/services/domains/publications/application.py b/build/lib/app/services/domains/publications/application.py new file mode 100644 index 0000000..b1161fb --- /dev/null +++ b/build/lib/app/services/domains/publications/application.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from app.services.domains.publications.counts import ( + count_for_user, + count_favorite_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 ( + hydrate_pdf_enrichment_state, + schedule_missing_pdf_enrichment_for_user, + schedule_retry_pdf_enrichment_for_row, +) +from app.services.domains.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, + get_publication_item_for_user, + publications_query, +) +from app.services.domains.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 + +__all__ = [ + "MODE_ALL", + "MODE_UNREAD", + "MODE_LATEST", + "MODE_NEW", + "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_pdf_queue_items", + "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", + "mark_all_unread_as_read_for_user", + "mark_selected_as_read_for_user", + "set_publication_favorite_for_user", +] diff --git a/build/lib/app/services/domains/publications/counts.py b/build/lib/app/services/domains/publications/counts.py new file mode 100644 index 0000000..8f65172 --- /dev/null +++ b/build/lib/app/services/domains/publications/counts.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ScholarProfile, ScholarPublication +from app.services.domains.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 + + +async def count_for_user( + db_session: AsyncSession, + *, + user_id: int, + mode: str = MODE_ALL, + scholar_profile_id: int | None = None, + favorite_only: bool = False, +) -> 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) + stmt = ( + select(func.count()) + .select_from(ScholarPublication) + .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) + .where(ScholarProfile.user_id == user_id) + ) + 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 resolved_mode == MODE_UNREAD: + stmt = stmt.where(ScholarPublication.is_read.is_(False)) + if resolved_mode == MODE_LATEST: + if latest_run_id is None: + return 0 + stmt = stmt.where(ScholarPublication.first_seen_run_id == latest_run_id) + result = await db_session.execute(stmt) + return int(result.scalar_one() or 0) + + +async def count_unread_for_user( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int | None = None, + favorite_only: bool = False, +) -> int: + return await count_for_user( + db_session, + user_id=user_id, + mode=MODE_UNREAD, + scholar_profile_id=scholar_profile_id, + favorite_only=favorite_only, + ) + + +async def count_latest_for_user( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int | None = None, + favorite_only: bool = False, +) -> int: + return await count_for_user( + db_session, + user_id=user_id, + mode=MODE_LATEST, + scholar_profile_id=scholar_profile_id, + favorite_only=favorite_only, + ) + + +async def count_favorite_for_user( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int | None = None, +) -> int: + return await count_for_user( + db_session, + user_id=user_id, + mode=MODE_ALL, + scholar_profile_id=scholar_profile_id, + favorite_only=True, + ) diff --git a/build/lib/app/services/domains/publications/enrichment.py b/build/lib/app/services/domains/publications/enrichment.py new file mode 100644 index 0000000..381107a --- /dev/null +++ b/build/lib/app/services/domains/publications/enrichment.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import logging + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.domains.publications.pdf_queue import ( + enqueue_missing_pdf_jobs, + enqueue_retry_pdf_job, + overlay_pdf_job_state, +) +from app.services.domains.publications.types import PublicationListItem + +logger = logging.getLogger(__name__) + + +async def schedule_missing_pdf_enrichment_for_user( + db_session: AsyncSession, + *, + user_id: int, + request_email: str | None, + items: list[PublicationListItem], + max_items: int, +) -> int: + queued_ids = await enqueue_missing_pdf_jobs( + db_session, + user_id=user_id, + request_email=request_email, + 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), + }, + ) + return len(queued_ids) + + +async def schedule_retry_pdf_enrichment_for_row( + db_session: AsyncSession, + *, + user_id: int, + request_email: str | None, + item: PublicationListItem, +) -> bool: + queued = await enqueue_retry_pdf_job( + db_session, + user_id=user_id, + request_email=request_email, + row=item, + ) + logger.info( + "publications.enrichment.retry_scheduled", + extra={ + "event": "publications.enrichment.retry_scheduled", + "user_id": user_id, + "publication_id": item.publication_id, + "queued": queued, + }, + ) + return queued + + +async def hydrate_pdf_enrichment_state( + db_session: AsyncSession, + *, + items: list[PublicationListItem], +) -> list[PublicationListItem]: + return await overlay_pdf_job_state(db_session, rows=items) diff --git a/build/lib/app/services/domains/publications/listing.py b/build/lib/app/services/domains/publications/listing.py new file mode 100644 index 0000000..0d09d4c --- /dev/null +++ b/build/lib/app/services/domains/publications/listing.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.domains.publication_identifiers import application as identifier_service +from app.services.domains.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, + 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 + + +async def list_for_user( + db_session: AsyncSession, + *, + user_id: int, + mode: str = MODE_ALL, + scholar_profile_id: int | None = None, + favorite_only: bool = False, + limit: int = 300, + offset: int = 0, +) -> 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) + result = await db_session.execute( + publications_query( + user_id=user_id, + mode=resolved_mode, + latest_run_id=latest_run_id, + scholar_profile_id=scholar_profile_id, + favorite_only=favorite_only, + limit=limit, + offset=offset, + ) + ) + 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( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int, + publication_id: int, +) -> PublicationListItem | None: + 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( + db_session: AsyncSession, + *, + user_id: int, + limit: int = 100, +) -> list[UnreadPublicationItem]: + result = await db_session.execute( + publications_query( + user_id=user_id, + mode=MODE_UNREAD, + latest_run_id=None, + scholar_profile_id=None, + favorite_only=False, + limit=limit, + offset=0, + ) + ) + return [unread_item_from_row(row) for row in result.all()] diff --git a/build/lib/app/services/domains/publications/modes.py b/build/lib/app/services/domains/publications/modes.py new file mode 100644 index 0000000..2975169 --- /dev/null +++ b/build/lib/app/services/domains/publications/modes.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +MODE_ALL = "all" +MODE_UNREAD = "unread" +MODE_LATEST = "latest" +MODE_NEW = "new" # compatibility alias for MODE_LATEST + + +def resolve_publication_view_mode(value: str | None) -> str: + if value == MODE_UNREAD: + return MODE_UNREAD + if value in {MODE_LATEST, MODE_NEW}: + return MODE_LATEST + return MODE_ALL diff --git a/build/lib/app/services/domains/publications/pdf_queue.py b/build/lib/app/services/domains/publications/pdf_queue.py new file mode 100644 index 0000000..18ac8d9 --- /dev/null +++ b/build/lib/app/services/domains/publications/pdf_queue.py @@ -0,0 +1,915 @@ +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.publication_identifiers import application as identifier_service +from app.services.domains.publication_identifiers.types import DisplayIdentifier +from app.services.domains.publications.pdf_resolution_pipeline import ( + resolve_publication_pdf_outcome_for_row, +) +from app.services.domains.publications.types import PublicationListItem +from app.services.domains.unpaywall.application import ( + FAILURE_RESOLUTION_EXCEPTION, + OaResolutionOutcome, +) +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 + 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 + + +@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, + 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 _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=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, +) -> OaResolutionOutcome: + pipeline_result = await resolve_publication_pdf_outcome_for_row( + row=row, + request_email=request_email, + ) + outcome = pipeline_result.outcome + if outcome is not None: + return outcome + return _failed_outcome(row=row) + + +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, +) -> 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, + 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, + 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.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)) + ) + + +def _queue_item_from_row(row: tuple) -> 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[tuple], +) -> 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, + ) + + +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, + ) + + +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/build/lib/app/services/domains/publications/pdf_resolution_pipeline.py b/build/lib/app/services/domains/publications/pdf_resolution_pipeline.py new file mode 100644 index 0000000..9e07c0b --- /dev/null +++ b/build/lib/app/services/domains/publications/pdf_resolution_pipeline.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Any + +from app.services.domains.publications.types import PublicationListItem +from app.services.domains.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 + + +async def resolve_publication_pdf_outcome_for_row( + *, + row: PublicationListItem, + request_email: str | None, +) -> PipelineOutcome: + # 1. OpenAlex OA + openalex_outcome = await _openalex_outcome(row, request_email=request_email) + if openalex_outcome and openalex_outcome.pdf_url: + return PipelineOutcome(openalex_outcome, None) + + # 2. arXiv + arxiv_outcome = await _arxiv_outcome(row, request_email=request_email) + if arxiv_outcome and arxiv_outcome.pdf_url: + return PipelineOutcome(arxiv_outcome, None) + + # 3. Unpaywall (which falls back to Crossref) + oa_outcome = await _oa_outcome(row=row, request_email=request_email) + return PipelineOutcome(oa_outcome, None) + + +async def _openalex_outcome(row: PublicationListItem, request_email: str | None) -> OaResolutionOutcome | None: + from app.services.domains.openalex.client import OpenAlexClient + from app.services.domains.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 + + client = OpenAlexClient(api_key=settings.openalex_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 Exception as exc: + logger.warning( + "publications.pdf_resolution.openalex_failed", + extra={"event": "publications.pdf_resolution.openalex_failed", "error": str(exc)}, + ) + return None + + +async def _arxiv_outcome(row: PublicationListItem, request_email: str | None) -> OaResolutionOutcome | None: + from app.services.domains.arxiv.application import discover_arxiv_id_for_publication + + 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 Exception as exc: + logger.warning( + "publications.pdf_resolution.arxiv_failed", + extra={"event": "publications.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/build/lib/app/services/domains/publications/queries.py b/build/lib/app/services/domains/publications/queries.py new file mode 100644 index 0000000..e7356f4 --- /dev/null +++ b/build/lib/app/services/domains/publications/queries.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from sqlalchemy import Select, func, select +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 + + +def _normalized_citation_count(value: object) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +async def get_latest_completed_run_id_for_user( + db_session: AsyncSession, + *, + user_id: int, +) -> int | None: + result = await db_session.execute( + select(func.max(CrawlRun.id)).where( + CrawlRun.user_id == user_id, + CrawlRun.status != RunStatus.RUNNING, + ) + ) + latest_run_id = result.scalar_one_or_none() + return int(latest_run_id) if latest_run_id is not None else None + + +def publications_query( + *, + user_id: int, + mode: str, + latest_run_id: int | None, + scholar_profile_id: int | None, + favorite_only: bool, + limit: int, + offset: int = 0, +) -> Select[tuple]: + scholar_label = ScholarProfile.display_name + stmt = ( + select( + Publication.id, + ScholarProfile.id, + scholar_label, + ScholarProfile.scholar_id, + Publication.title_raw, + Publication.year, + Publication.citation_count, + Publication.venue_text, + Publication.pub_url, + Publication.pdf_url, + ScholarPublication.is_read, + ScholarPublication.is_favorite, + ScholarPublication.first_seen_run_id, + ScholarPublication.created_at, + ) + .join(ScholarPublication, ScholarPublication.publication_id == Publication.id) + .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) + .where(ScholarProfile.user_id == user_id) + .order_by(ScholarPublication.created_at.desc(), Publication.id.desc()) + .offset(max(int(offset), 0)) + .limit(limit) + ) + 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 mode == MODE_UNREAD: + stmt = stmt.where(ScholarPublication.is_read.is_(False)) + if mode == MODE_LATEST: + if latest_run_id is None: + return stmt.where(False) + stmt = stmt.where(ScholarPublication.first_seen_run_id == latest_run_id) + return stmt + + +def publication_query_for_user( + *, + user_id: int, + scholar_profile_id: int, + publication_id: int, +) -> Select[tuple]: + return ( + select( + Publication.id, + ScholarProfile.id, + ScholarProfile.display_name, + ScholarProfile.scholar_id, + Publication.title_raw, + Publication.year, + Publication.citation_count, + Publication.venue_text, + Publication.pub_url, + Publication.pdf_url, + ScholarPublication.is_read, + ScholarPublication.is_favorite, + ScholarPublication.first_seen_run_id, + ScholarPublication.created_at, + ) + .join(ScholarPublication, ScholarPublication.publication_id == Publication.id) + .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) + .where( + ScholarProfile.user_id == user_id, + ScholarProfile.id == scholar_profile_id, + Publication.id == publication_id, + ) + .limit(1) + ) + + +async def get_publication_item_for_user( + db_session: AsyncSession, + *, + user_id: int, + 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) + result = await db_session.execute( + publication_query_for_user( + user_id=user_id, + scholar_profile_id=scholar_profile_id, + publication_id=publication_id, + ) + ) + row = result.one_or_none() + if row is None: + return None + return publication_list_item_from_row(row, latest_run_id=latest_run_id) + + +def publication_list_item_from_row( + row: tuple, + *, + latest_run_id: int | None, +) -> PublicationListItem: + ( + publication_id, + scholar_profile_id, + display_name, + scholar_id, + title_raw, + year, + citation_count, + venue_text, + pub_url, + pdf_url, + is_read, + is_favorite, + first_seen_run_id, + created_at, + ) = row + return PublicationListItem( + publication_id=int(publication_id), + scholar_profile_id=int(scholar_profile_id), + scholar_label=(display_name or scholar_id), + title=title_raw, + year=year, + citation_count=_normalized_citation_count(citation_count), + venue_text=venue_text, + pub_url=pub_url, + 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 + ), + ) + + +def unread_item_from_row(row: tuple) -> UnreadPublicationItem: + ( + publication_id, + scholar_profile_id, + display_name, + scholar_id, + title_raw, + year, + citation_count, + venue_text, + pub_url, + pdf_url, + _is_read, + _is_favorite, + _first_seen_run_id, + _created_at, + ) = row + return UnreadPublicationItem( + publication_id=int(publication_id), + scholar_profile_id=int(scholar_profile_id), + scholar_label=(display_name or scholar_id), + title=title_raw, + year=year, + citation_count=_normalized_citation_count(citation_count), + venue_text=venue_text, + pub_url=pub_url, + pdf_url=pdf_url, + ) diff --git a/build/lib/app/services/domains/publications/read_state.py b/build/lib/app/services/domains/publications/read_state.py new file mode 100644 index 0000000..41fc31a --- /dev/null +++ b/build/lib/app/services/domains/publications/read_state.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from sqlalchemy import select, tuple_, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ScholarProfile, ScholarPublication + + +def _normalized_selection_pairs(selections: list[tuple[int, int]]) -> set[tuple[int, int]]: + pairs: set[tuple[int, int]] = set() + for scholar_profile_id, publication_id in selections: + normalized = (int(scholar_profile_id), int(publication_id)) + if normalized[0] <= 0 or normalized[1] <= 0: + continue + pairs.add(normalized) + return pairs + + +def _scoped_scholar_ids_query(*, user_id: int): + return ( + select(ScholarProfile.id) + .where(ScholarProfile.user_id == user_id) + .scalar_subquery() + ) + + +async def mark_all_unread_as_read_for_user( + db_session: AsyncSession, + *, + user_id: int, +) -> int: + scholar_ids = _scoped_scholar_ids_query(user_id=user_id) + stmt = ( + update(ScholarPublication) + .where( + ScholarPublication.scholar_profile_id.in_(scholar_ids), + ScholarPublication.is_read.is_(False), + ) + .values(is_read=True) + ) + result = await db_session.execute(stmt) + await db_session.commit() + return int(result.rowcount or 0) + + +async def mark_selected_as_read_for_user( + db_session: AsyncSession, + *, + user_id: int, + selections: list[tuple[int, int]], +) -> int: + normalized_pairs = _normalized_selection_pairs(selections) + if not normalized_pairs: + return 0 + + scholar_ids = _scoped_scholar_ids_query(user_id=user_id) + stmt = ( + update(ScholarPublication) + .where( + ScholarPublication.scholar_profile_id.in_(scholar_ids), + tuple_( + ScholarPublication.scholar_profile_id, + ScholarPublication.publication_id, + ).in_(list(normalized_pairs)), + ScholarPublication.is_read.is_(False), + ) + .values(is_read=True) + ) + result = await db_session.execute(stmt) + await db_session.commit() + return int(result.rowcount or 0) + + +async def set_publication_favorite_for_user( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int, + publication_id: int, + is_favorite: bool, +) -> int: + scholar_ids = _scoped_scholar_ids_query(user_id=user_id) + stmt = ( + update(ScholarPublication) + .where( + ScholarPublication.scholar_profile_id.in_(scholar_ids), + ScholarPublication.scholar_profile_id == int(scholar_profile_id), + ScholarPublication.publication_id == int(publication_id), + ) + .values(is_favorite=bool(is_favorite)) + ) + result = await db_session.execute(stmt) + await db_session.commit() + return int(result.rowcount or 0) diff --git a/build/lib/app/services/domains/publications/types.py b/build/lib/app/services/domains/publications/types.py new file mode 100644 index 0000000..62d038b --- /dev/null +++ b/build/lib/app/services/domains/publications/types.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from app.services.domains.publication_identifiers.types import DisplayIdentifier + + +@dataclass(frozen=True) +class PublicationListItem: + 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 + pdf_url: str | None + is_read: bool + first_seen_at: datetime + is_new_in_latest_run: bool + is_favorite: bool = False + pdf_status: str = "untracked" + 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) +class UnreadPublicationItem: + 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 + pdf_url: str | None diff --git a/build/lib/app/services/domains/runs/__init__.py b/build/lib/app/services/domains/runs/__init__.py new file mode 100644 index 0000000..848f6e0 --- /dev/null +++ b/build/lib/app/services/domains/runs/__init__.py @@ -0,0 +1 @@ +from app.services.domains.runs.application import * diff --git a/build/lib/app/services/domains/runs/application.py b/build/lib/app/services/domains/runs/application.py new file mode 100644 index 0000000..0019446 --- /dev/null +++ b/build/lib/app/services/domains/runs/application.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from app.services.domains.runs.queue_service import ( + clear_queue_item_for_user, + drop_queue_item_for_user, + get_queue_item_for_user, + list_queue_items_for_user, + queue_status_counts_for_user, + retry_queue_item_for_user, +) +from app.services.domains.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 ( + QUEUE_STATUS_DROPPED, + QUEUE_STATUS_QUEUED, + QUEUE_STATUS_RETRYING, + QueueClearResult, + QueueListItem, + QueueTransitionError, +) + +__all__ = [ + "QUEUE_STATUS_QUEUED", + "QUEUE_STATUS_RETRYING", + "QUEUE_STATUS_DROPPED", + "QueueListItem", + "QueueClearResult", + "QueueTransitionError", + "extract_run_summary", + "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", +] diff --git a/build/lib/app/services/domains/runs/events.py b/build/lib/app/services/domains/runs/events.py new file mode 100644 index 0000000..ef28069 --- /dev/null +++ b/build/lib/app/services/domains/runs/events.py @@ -0,0 +1,59 @@ +import asyncio +import json +import logging +from typing import Any, AsyncGenerator, Dict, Set + +logger = logging.getLogger(__name__) + +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() + self._subscribers[run_id].add(queue) + logger.debug(f"New subscriber for run {run_id}. Total: {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: + logger.warning(f"Subscriber queue full for run {run_id}, dropping message") + +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() + # Server-Sent Events format: "event: \ndata: \n\n" + event_type = message["type"] + data_str = json.dumps(message["data"]) + yield f"event: {event_type}\ndata: {data_str}\n\n" + except asyncio.CancelledError: + logger.debug(f"Client disconnected from SSE stream for run {run_id}") + raise + finally: + run_events.unsubscribe(run_id, queue) diff --git a/build/lib/app/services/domains/runs/queue_queries.py b/build/lib/app/services/domains/runs/queue_queries.py new file mode 100644 index 0000000..7d2e501 --- /dev/null +++ b/build/lib/app/services/domains/runs/queue_queries.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from sqlalchemy import and_, select + +from app.db.models import IngestionQueueItem, ScholarProfile +from app.services.domains.runs.types import QueueListItem + + +def queue_item_columns() -> tuple: + return ( + IngestionQueueItem.id, + IngestionQueueItem.scholar_profile_id, + ScholarProfile.display_name, + ScholarProfile.scholar_id, + IngestionQueueItem.status, + IngestionQueueItem.reason, + IngestionQueueItem.dropped_reason, + IngestionQueueItem.attempt_count, + IngestionQueueItem.resume_cstart, + IngestionQueueItem.next_attempt_dt, + IngestionQueueItem.updated_at, + IngestionQueueItem.last_error, + IngestionQueueItem.last_run_id, + ) + + +def queue_item_select(*, user_id: int): + return ( + select(*queue_item_columns()) + .join( + ScholarProfile, + and_( + ScholarProfile.id == IngestionQueueItem.scholar_profile_id, + ScholarProfile.user_id == IngestionQueueItem.user_id, + ), + ) + .where(IngestionQueueItem.user_id == user_id) + ) + + +def queue_list_item_from_row(row: tuple) -> QueueListItem: + ( + item_id, + scholar_profile_id, + display_name, + scholar_id, + status, + reason, + dropped_reason, + attempt_count, + resume_cstart, + next_attempt_dt, + updated_at, + last_error, + last_run_id, + ) = row + return QueueListItem( + id=int(item_id), + scholar_profile_id=int(scholar_profile_id), + scholar_label=(display_name or scholar_id), + status=str(status), + reason=str(reason), + dropped_reason=dropped_reason, + attempt_count=int(attempt_count or 0), + resume_cstart=int(resume_cstart or 0), + next_attempt_dt=next_attempt_dt, + updated_at=updated_at, + last_error=last_error, + last_run_id=int(last_run_id) if last_run_id is not None else None, + ) diff --git a/build/lib/app/services/domains/runs/queue_service.py b/build/lib/app/services/domains/runs/queue_service.py new file mode 100644 index 0000000..d69ce21 --- /dev/null +++ b/build/lib/app/services/domains/runs/queue_service.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +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 ( + QUEUE_STATUS_DROPPED, + QUEUE_STATUS_QUEUED, + QUEUE_STATUS_RETRYING, + QueueClearResult, + QueueListItem, + QueueTransitionError, +) + + +async def list_queue_items_for_user( + db_session: AsyncSession, + *, + user_id: int, + limit: int = 200, +) -> list[QueueListItem]: + result = await db_session.execute( + queue_item_select(user_id=user_id) + .order_by( + case((IngestionQueueItem.status == QUEUE_STATUS_DROPPED, 1), else_=0).asc(), + IngestionQueueItem.next_attempt_dt.asc(), + IngestionQueueItem.id.asc(), + ) + .limit(limit) + ) + return [queue_list_item_from_row(row) for row in result.all()] + + +async def get_queue_item_for_user( + db_session: AsyncSession, + *, + user_id: int, + 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) + ) + row = result.one_or_none() + if row is None: + return None + return queue_list_item_from_row(row) + + +async def retry_queue_item_for_user( + db_session: AsyncSession, + *, + user_id: int, + queue_item_id: int, +) -> QueueListItem | None: + result = await db_session.execute( + select(IngestionQueueItem).where( + IngestionQueueItem.id == queue_item_id, + IngestionQueueItem.user_id == user_id, + ) + ) + item = result.scalar_one_or_none() + if item is None: + return None + if item.status == QUEUE_STATUS_QUEUED: + raise QueueTransitionError( + code="queue_item_already_queued", + message="Queue item is already queued.", + current_status=item.status, + ) + if item.status == QUEUE_STATUS_RETRYING: + raise QueueTransitionError( + code="queue_item_retrying", + message="Queue item is currently retrying.", + current_status=item.status, + ) + + await queue_mutations.mark_queued_now( + db_session, + job_id=item.id, + reason="manual_retry", + reset_attempt_count=(item.status == QUEUE_STATUS_DROPPED), + ) + await db_session.commit() + return await get_queue_item_for_user( + db_session, + user_id=user_id, + queue_item_id=queue_item_id, + ) + + +async def drop_queue_item_for_user( + db_session: AsyncSession, + *, + user_id: int, + queue_item_id: int, +) -> QueueListItem | None: + result = await db_session.execute( + select(IngestionQueueItem).where( + IngestionQueueItem.id == queue_item_id, + IngestionQueueItem.user_id == user_id, + ) + ) + item = result.scalar_one_or_none() + if item is None: + return None + if item.status == QUEUE_STATUS_DROPPED: + raise QueueTransitionError( + code="queue_item_already_dropped", + message="Queue item is already dropped.", + current_status=item.status, + ) + + await queue_mutations.mark_dropped( + db_session, + job_id=int(item.id), + reason="manual_drop", + ) + await db_session.commit() + return await get_queue_item_for_user( + db_session, + user_id=user_id, + queue_item_id=queue_item_id, + ) + + +async def clear_queue_item_for_user( + db_session: AsyncSession, + *, + user_id: int, + queue_item_id: int, +) -> QueueClearResult | None: + result = await db_session.execute( + select(IngestionQueueItem).where( + IngestionQueueItem.id == queue_item_id, + IngestionQueueItem.user_id == user_id, + ) + ) + item = result.scalar_one_or_none() + if item is None: + return None + if item.status != QUEUE_STATUS_DROPPED: + raise QueueTransitionError( + code="queue_item_not_dropped", + message="Queue item can only be cleared after it is dropped.", + current_status=item.status, + ) + + item_id = int(item.id) + previous_status = str(item.status) + deleted = await queue_mutations.delete_job_by_id(db_session, job_id=item_id) + await db_session.commit() + if not deleted: + return None + return QueueClearResult(queue_item_id=item_id, previous_status=previous_status) + + +async def queue_status_counts_for_user( + db_session: AsyncSession, + *, + user_id: int, +) -> dict[str, int]: + result = await db_session.execute( + select( + IngestionQueueItem.status, + func.count(IngestionQueueItem.id), + ) + .where(IngestionQueueItem.user_id == user_id) + .group_by(IngestionQueueItem.status) + ) + counts: dict[str, int] = { + QUEUE_STATUS_QUEUED: 0, + QUEUE_STATUS_RETRYING: 0, + QUEUE_STATUS_DROPPED: 0, + } + for status, count in result.all(): + counts[str(status)] = int(count or 0) + return counts diff --git a/build/lib/app/services/domains/runs/runs_service.py b/build/lib/app/services/domains/runs/runs_service.py new file mode 100644 index 0000000..1cb9aa2 --- /dev/null +++ b/build/lib/app/services/domains/runs/runs_service.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import CrawlRun, RunStatus, RunTriggerType + + +async def list_recent_runs_for_user( + db_session: AsyncSession, + *, + user_id: int, + limit: int = 20, +) -> list[CrawlRun]: + result = await db_session.execute( + select(CrawlRun) + .where(CrawlRun.user_id == user_id) + .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) + .limit(limit) + ) + return list(result.scalars().all()) + + +async def list_runs_for_user( + db_session: AsyncSession, + *, + user_id: int, + limit: int = 100, + failed_only: bool = False, +) -> list[CrawlRun]: + stmt = ( + select(CrawlRun) + .where(CrawlRun.user_id == user_id) + .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) + .limit(limit) + ) + if failed_only: + stmt = stmt.where( + CrawlRun.status.in_([RunStatus.FAILED, RunStatus.PARTIAL_FAILURE]) + ) + result = await db_session.execute(stmt) + return list(result.scalars().all()) + + +async def get_run_for_user( + db_session: AsyncSession, + *, + user_id: int, + run_id: int, +) -> CrawlRun | None: + result = await db_session.execute( + select(CrawlRun).where( + CrawlRun.user_id == user_id, + CrawlRun.id == run_id, + ) + ) + return result.scalar_one_or_none() + + +async def get_manual_run_by_idempotency_key( + db_session: AsyncSession, + *, + user_id: int, + idempotency_key: str, +) -> CrawlRun | None: + result = await db_session.execute( + select(CrawlRun) + .where( + CrawlRun.user_id == user_id, + CrawlRun.trigger_type == RunTriggerType.MANUAL, + or_( + CrawlRun.idempotency_key == idempotency_key, + CrawlRun.error_log["meta"]["idempotency_key"].astext == idempotency_key, + ), + ) + .order_by(CrawlRun.start_dt.desc(), CrawlRun.id.desc()) + .limit(1) + ) + return result.scalar_one_or_none() diff --git a/build/lib/app/services/domains/runs/summary.py b/build/lib/app/services/domains/runs/summary.py new file mode 100644 index 0000000..9555558 --- /dev/null +++ b/build/lib/app/services/domains/runs/summary.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import Any + + +def _safe_int(value: object, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _summary_dict(error_log: object) -> dict[str, Any]: + if not isinstance(error_log, dict): + return {} + summary = error_log.get("summary") + if not isinstance(summary, dict): + return {} + return summary + + +def _summary_int_dict(summary: dict[str, Any], key: str) -> dict[str, int]: + value = summary.get(key) + 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) + } + + +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) + } + + +def _retry_counts(summary: dict[str, Any]) -> dict[str, int]: + retry_counts = summary.get("retry_counts") + if not isinstance(retry_counts, dict): + retry_counts = {} + return { + "retries_scheduled_count": _safe_int( + retry_counts.get("retries_scheduled_count", 0), + 0, + ), + "scholars_with_retries_count": _safe_int( + retry_counts.get("scholars_with_retries_count", 0), + 0, + ), + "retry_exhausted_count": _safe_int( + retry_counts.get("retry_exhausted_count", 0), + 0, + ), + } + + +def extract_run_summary(error_log: object) -> dict[str, Any]: + summary = _summary_dict(error_log) + return { + "succeeded_count": _safe_int(summary.get("succeeded_count", 0)), + "failed_count": _safe_int(summary.get("failed_count", 0)), + "partial_count": _safe_int(summary.get("partial_count", 0)), + "failed_state_counts": _summary_int_dict(summary, "failed_state_counts"), + "failed_reason_counts": _summary_int_dict(summary, "failed_reason_counts"), + "scrape_failure_counts": _summary_int_dict(summary, "scrape_failure_counts"), + "retry_counts": _retry_counts(summary), + "alert_thresholds": _summary_int_dict(summary, "alert_thresholds"), + "alert_flags": _summary_bool_dict(summary, "alert_flags"), + } diff --git a/build/lib/app/services/domains/runs/types.py b/build/lib/app/services/domains/runs/types.py new file mode 100644 index 0000000..2524269 --- /dev/null +++ b/build/lib/app/services/domains/runs/types.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +QUEUE_STATUS_QUEUED = "queued" +QUEUE_STATUS_RETRYING = "retrying" +QUEUE_STATUS_DROPPED = "dropped" + + +@dataclass(frozen=True) +class QueueListItem: + 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 + + +@dataclass(frozen=True) +class QueueClearResult: + queue_item_id: int + previous_status: str + + +class QueueTransitionError(RuntimeError): + def __init__(self, *, code: str, message: str, current_status: str) -> None: + super().__init__(message) + self.code = code + self.message = message + self.current_status = current_status diff --git a/build/lib/app/services/domains/scholar/__init__.py b/build/lib/app/services/domains/scholar/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/build/lib/app/services/domains/scholar/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/build/lib/app/services/domains/scholar/author_rows.py b/build/lib/app/services/domains/scholar/author_rows.py new file mode 100644 index 0000000..8acd783 --- /dev/null +++ b/build/lib/app/services/domains/scholar/author_rows.py @@ -0,0 +1,202 @@ +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 AUTHOR_SEARCH_MARKER_KEYS +from app.services.domains.scholar.parser_types import ScholarSearchCandidate +from app.services.domains.scholar.parser_utils import ( + attr_class, + attr_href, + attr_src, + build_absolute_scholar_url, + normalize_space, +) + + +def parse_scholar_id_from_href(href: str | None) -> str | None: + if not href: + return None + parsed = urlparse(href) + query = parse_qs(parsed.query) + user_values = query.get("user") + if not user_values: + return None + candidate = user_values[0].strip() + return candidate or None + + +def _extract_verified_email_domain(value: str | None) -> str | None: + if not value: + return None + match = re.search(r"verified email at\s+(.+)$", value.strip(), re.I) + if not match: + return None + domain = normalize_space(match.group(1)) + return domain or None + + +class ScholarAuthorSearchParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.candidates: list[ScholarSearchCandidate] = [] + self._candidate: dict[str, Any] | None = None + + def _begin_candidate(self) -> None: + self._candidate = { + "depth": 1, + "name_href": None, + "name_parts": [], + "aff_depth": 0, + "aff_parts": [], + "name_depth": 0, + "eml_depth": 0, + "eml_parts": [], + "cby_depth": 0, + "cby_parts": [], + "interest_depth": 0, + "interest_parts": [], + "interests": [], + "image_src": None, + } + + def _increment_capture_depths(self) -> None: + if self._candidate is None: + return + for key in ("name_depth", "aff_depth", "eml_depth", "cby_depth", "interest_depth"): + if self._candidate[key] > 0: + self._candidate[key] += 1 + + def _finalize_candidate(self) -> None: + if self._candidate is None: + return + + name = normalize_space("".join(self._candidate["name_parts"])) + scholar_id = parse_scholar_id_from_href(self._candidate["name_href"]) + if not name or not scholar_id: + 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 + ) + 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 + + seen_interests: set[str] = set() + interests: list[str] = [] + for interest in self._candidate["interests"]: + normalized = normalize_space(interest) + if not normalized or normalized in seen_interests: + continue + seen_interests.add(normalized) + interests.append(normalized) + + 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}" + ) + + self.candidates.append( + ScholarSearchCandidate( + scholar_id=scholar_id, + display_name=name, + affiliation=affiliation, + email_domain=email_domain, + cited_by_count=cited_by_count, + interests=interests, + profile_url=profile_url, + profile_image_url=build_absolute_scholar_url(self._candidate["image_src"]), + ) + ) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + classes = attr_class(attrs) + + if self._candidate is None: + if tag == "div" and "gsc_1usr" in classes: + self._begin_candidate() + return + + self._candidate["depth"] += 1 + self._increment_capture_depths() + + if tag == "a" and "gs_ai_name" in classes: + self._candidate["name_depth"] = 1 + self._candidate["name_href"] = attr_href(attrs) + return + + if tag == "div" and "gs_ai_aff" in classes: + self._candidate["aff_depth"] = 1 + return + + if tag == "div" and "gs_ai_eml" in classes: + self._candidate["eml_depth"] = 1 + return + + if tag == "div" and "gs_ai_cby" in classes: + self._candidate["cby_depth"] = 1 + return + + if tag == "a" and "gs_ai_one_int" in classes: + self._candidate["interest_depth"] = 1 + self._candidate["interest_parts"] = [] + return + + if tag == "img" and self._candidate["image_src"] is None: + self._candidate["image_src"] = attr_src(attrs) + + def handle_data(self, data: str) -> None: + if self._candidate is None: + return + if self._candidate["name_depth"] > 0: + self._candidate["name_parts"].append(data) + if self._candidate["aff_depth"] > 0: + self._candidate["aff_parts"].append(data) + if self._candidate["eml_depth"] > 0: + self._candidate["eml_parts"].append(data) + if self._candidate["cby_depth"] > 0: + self._candidate["cby_parts"].append(data) + if self._candidate["interest_depth"] > 0: + self._candidate["interest_parts"].append(data) + + def _decrement_capture_depth(self, key: str) -> bool: + if self._candidate is None: + return False + if self._candidate[key] <= 0: + return False + self._candidate[key] -= 1 + return self._candidate[key] == 0 + + def handle_endtag(self, _tag: str) -> None: + if self._candidate is None: + return + + interest_closed = self._decrement_capture_depth("interest_depth") + self._decrement_capture_depth("name_depth") + self._decrement_capture_depth("aff_depth") + self._decrement_capture_depth("eml_depth") + self._decrement_capture_depth("cby_depth") + + if interest_closed: + interest_text = normalize_space("".join(self._candidate["interest_parts"])) + if interest_text: + self._candidate["interests"].append(interest_text) + self._candidate["interest_parts"] = [] + + self._candidate["depth"] -= 1 + if self._candidate["depth"] > 0: + return + + self._finalize_candidate() + self._candidate = None + + +def count_author_search_markers(html: str) -> dict[str, int]: + lowered = html.lower() + return {key: lowered.count(key.lower()) for key in AUTHOR_SEARCH_MARKER_KEYS} diff --git a/build/lib/app/services/domains/scholar/parser.py b/build/lib/app/services/domains/scholar/parser.py new file mode 100644 index 0000000..060e825 --- /dev/null +++ b/build/lib/app/services/domains/scholar/parser.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from app.services.domains.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, + ParsedAuthorSearchPage, + ParsedProfilePage, + PublicationCandidate, + ScholarDomInvariantError, + ScholarMalformedDataError, + ScholarParserError, + ScholarSearchCandidate, +) +from app.services.domains.scholar.parser_utils import ( + strip_tags, +) +from app.services.domains.scholar.profile_rows import ( + ScholarRowParser, + 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, + detect_author_search_state, + detect_state, +) + + +def _raise_dom_error(code: str, message: str) -> None: + raise ScholarDomInvariantError(code=code, message=message) + + +def _assert_profile_dom_invariants( + *, + fetch_result: FetchResult, + marker_counts: dict[str, int], + publications: list[PublicationCandidate], + warnings: list[str], + has_show_more_button_flag: bool, + articles_range: str | None, +) -> None: + if fetch_result.status_code is None: + return + final_url = (fetch_result.final_url or "").lower() + if "accounts.google.com" in final_url or "sorry/index" in final_url: + return + if any(code.startswith("layout_") for code in warnings): + reason = next(code for code in warnings if code.startswith("layout_")) + _raise_dom_error(reason, f"Detected layout warning: {reason}") + if has_show_more_button_flag and not articles_range: + _raise_dom_error( + "layout_show_more_without_articles_range", + "Show-more control exists without an articles range marker.", + ) + if marker_counts.get("gsc_a_tr", 0) > 0 and marker_counts.get("gsc_a_at", 0) <= 0: + _raise_dom_error( + "layout_missing_publication_title_anchor", + "Publication rows were present but title anchors were absent.", + ) + if not publications: + has_profile_markers = marker_counts.get("gsc_prf_in", 0) > 0 + has_table_markers = marker_counts.get("gsc_a_tr", 0) > 0 or marker_counts.get("gsc_a_at", 0) > 0 + if not has_profile_markers and not has_table_markers: + _raise_dom_error("layout_markers_missing", "Expected scholar profile markers were absent.") + for publication in publications: + if not publication.title.strip(): + raise ScholarMalformedDataError( + code="malformed_publication_title", + message="Encountered a publication candidate with an empty title.", + ) + if publication.citation_count is not None and int(publication.citation_count) < 0: + raise ScholarMalformedDataError( + code="malformed_publication_negative_citations", + message="Encountered a publication candidate with negative citations.", + ) + + +def parse_profile_page(fetch_result: FetchResult) -> ParsedProfilePage: + publications, warnings = parse_publications(fetch_result.body) + marker_counts = count_markers(fetch_result.body) + visible_text = strip_tags(SCRIPT_STYLE_RE.sub(" ", fetch_result.body)).lower() + + show_more = has_show_more_button(fetch_result.body) + operation_error_banner = has_operation_error_banner(fetch_result.body) + articles_range = extract_articles_range(fetch_result.body) + + if show_more: + warnings.append("possible_partial_page_show_more_present") + if operation_error_banner: + warnings.append("operation_error_banner_present") + + warnings = sorted(set(warnings)) + _assert_profile_dom_invariants( + fetch_result=fetch_result, + marker_counts=marker_counts, + publications=publications, + warnings=warnings, + has_show_more_button_flag=show_more, + articles_range=articles_range, + ) + + state, state_reason = detect_state( + fetch_result, + publications, + marker_counts, + warnings=warnings, + has_show_more_button_flag=show_more, + articles_range=articles_range, + visible_text=visible_text, + ) + + return ParsedProfilePage( + state=state, + state_reason=state_reason, + profile_name=extract_profile_name(fetch_result.body), + profile_image_url=extract_profile_image_url(fetch_result.body), + publications=publications, + marker_counts=marker_counts, + warnings=warnings, + has_show_more_button=show_more, + has_operation_error_banner=operation_error_banner, + articles_range=articles_range, + ) + + +def parse_author_search_page(fetch_result: FetchResult) -> ParsedAuthorSearchPage: + parser = ScholarAuthorSearchParser() + parser.feed(fetch_result.body) + + marker_counts = count_author_search_markers(fetch_result.body) + visible_text = strip_tags(SCRIPT_STYLE_RE.sub(" ", fetch_result.body)).lower() + warnings: list[str] = [] + if not parser.candidates: + warnings.append("no_author_candidates_detected") + + state, state_reason = detect_author_search_state( + fetch_result, + parser.candidates, + marker_counts, + visible_text=visible_text, + ) + + return ParsedAuthorSearchPage( + state=state, + state_reason=state_reason, + candidates=parser.candidates, + marker_counts=marker_counts, + warnings=warnings, + ) diff --git a/build/lib/app/services/domains/scholar/parser_constants.py b/build/lib/app/services/domains/scholar/parser_constants.py new file mode 100644 index 0000000..c3c7a15 --- /dev/null +++ b/build/lib/app/services/domains/scholar/parser_constants.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import re + +BLOCKED_KEYWORDS = [ + "unusual traffic", + "sorry/index", + "not a robot", + "our systems have detected", + "automated queries", + "recaptcha", + "captcha", +] + +NO_RESULTS_KEYWORDS = [ + "didn't match any articles", + "did not match any articles", + "no articles", + "no documents", +] + +NO_AUTHOR_RESULTS_KEYWORDS = [ + "didn't match any user profiles", + "did not match any user profiles", + "didn't match any scholars", + "did not match any scholars", + "no user profiles", +] + +MARKER_KEYS = [ + "gsc_a_tr", + "gsc_a_at", + "gsc_a_ac", + "gsc_a_h", + "gsc_a_y", + "gs_gray", + "gsc_prf_in", + "gsc_rsb_st", +] + +AUTHOR_SEARCH_MARKER_KEYS = [ + "gsc_1usr", + "gs_ai_name", + "gs_ai_aff", + "gs_ai_eml", + "gs_ai_cby", + "gs_ai_one_int", +] + +NETWORK_DNS_ERROR_KEYWORDS = [ + "temporary failure in name resolution", + "name or service not known", + "nodename nor servname provided", + "getaddrinfo failed", +] + +NETWORK_TIMEOUT_KEYWORDS = [ + "timed out", + "timeout", +] + +NETWORK_TLS_ERROR_KEYWORDS = [ + "ssl", + "tls", + "certificate verify failed", +] + +TAG_RE = re.compile(r"<[^>]+>", re.S) +SCRIPT_STYLE_RE = re.compile(r"<(script|style)\b[^>]*>.*?", re.I | re.S) +SHOW_MORE_BUTTON_RE = re.compile( + r"]*\bid\s*=\s*['\"]gsc_bpf_more['\"][^>]*>", + re.I | re.S, +) + +PROFILE_ROW_PARSER_DIRECT_MARKERS = ( + "gs_ggs", + "gs_ggsd", + "gs_ggsa", + "gs_or_ggsm", +) + +PROFILE_ROW_DIRECT_LABEL_TOKENS = ( + "pdf", + "[pdf]", + "full text", + "download", +) diff --git a/build/lib/app/services/domains/scholar/parser_types.py b/build/lib/app/services/domains/scholar/parser_types.py new file mode 100644 index 0000000..7b657d8 --- /dev/null +++ b/build/lib/app/services/domains/scholar/parser_types.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class ParseState(StrEnum): + OK = "ok" + NO_RESULTS = "no_results" + BLOCKED_OR_CAPTCHA = "blocked_or_captcha" + LAYOUT_CHANGED = "layout_changed" + NETWORK_ERROR = "network_error" + + +class ScholarParserError(RuntimeError): + code: str + + def __init__(self, *, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +class ScholarDomInvariantError(ScholarParserError): + pass + + +class ScholarMalformedDataError(ScholarParserError): + pass + + +@dataclass(frozen=True) +class PublicationCandidate: + title: str + title_url: str | None + cluster_id: str | None + year: int | None + citation_count: int | None + authors_text: str | None + venue_text: str | None + pdf_url: str | None + + +@dataclass(frozen=True) +class ScholarSearchCandidate: + scholar_id: str + display_name: str + affiliation: str | None + email_domain: str | None + cited_by_count: int | None + interests: list[str] + profile_url: str + profile_image_url: str | None + + +@dataclass(frozen=True) +class ParsedProfilePage: + state: ParseState + state_reason: str + profile_name: str | None + profile_image_url: str | None + publications: list[PublicationCandidate] + marker_counts: dict[str, int] + warnings: list[str] + has_show_more_button: bool + has_operation_error_banner: bool + articles_range: str | None + + +@dataclass(frozen=True) +class ParsedAuthorSearchPage: + state: ParseState + state_reason: str + candidates: list[ScholarSearchCandidate] + marker_counts: dict[str, int] + warnings: list[str] diff --git a/build/lib/app/services/domains/scholar/parser_utils.py b/build/lib/app/services/domains/scholar/parser_utils.py new file mode 100644 index 0000000..1bf8687 --- /dev/null +++ b/build/lib/app/services/domains/scholar/parser_utils.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from html import unescape + +from app.services.domains.scholar.parser_constants import TAG_RE + + +def normalize_space(value: str) -> str: + return " ".join(unescape(value).split()) + + +def strip_tags(value: str) -> str: + return normalize_space(TAG_RE.sub(" ", value)) + + +def attr_class(attrs: list[tuple[str, str | None]]) -> str: + for name, raw_value in attrs: + if name.lower() == "class": + return raw_value or "" + return "" + + +def attr_href(attrs: list[tuple[str, str | None]]) -> str | None: + for name, raw_value in attrs: + if name.lower() == "href": + return raw_value + return None + + +def attr_src(attrs: list[tuple[str, str | None]]) -> str | None: + for name, raw_value in attrs: + if name.lower() == "src": + return raw_value + return None + + +def build_absolute_scholar_url(path_or_url: str | None) -> str | None: + if not path_or_url: + return None + from urllib.parse import urljoin + + return urljoin("https://scholar.google.com", path_or_url) diff --git a/build/lib/app/services/domains/scholar/profile_rows.py b/build/lib/app/services/domains/scholar/profile_rows.py new file mode 100644 index 0000000..6a63274 --- /dev/null +++ b/build/lib/app/services/domains/scholar/profile_rows.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +import re +from html.parser import HTMLParser +from urllib.parse import parse_qs, urlparse + +from app.services.domains.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 ( + attr_class, + attr_href, + attr_src, + build_absolute_scholar_url, + normalize_space, + strip_tags, +) + + +class ScholarRowParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.title_href: str | None = None + self.title_parts: list[str] = [] + self.citation_parts: list[str] = [] + self.year_parts: list[str] = [] + self.gray_texts: list[str] = [] + + self._title_depth = 0 + self._citation_depth = 0 + self._year_depth = 0 + self._gray_stack: list[dict[str, object]] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if self._title_depth > 0: + self._title_depth += 1 + if self._citation_depth > 0: + self._citation_depth += 1 + if self._year_depth > 0: + self._year_depth += 1 + if self._gray_stack: + self._gray_stack[-1]["depth"] += 1 + + classes = attr_class(attrs) + + if tag == "a" and "gsc_a_at" in classes: + self._title_depth = 1 + self.title_href = attr_href(attrs) + return + + if tag == "a" and "gsc_a_ac" in classes: + self._citation_depth = 1 + return + + if tag in {"span", "a"} and ("gsc_a_h" in classes or "gsc_a_y" in classes): + self._year_depth = 1 + return + + if tag == "div" and "gs_gray" in classes: + self._gray_stack.append({"depth": 1, "parts": []}) + return + + def handle_data(self, data: str) -> None: + if self._title_depth > 0: + self.title_parts.append(data) + if self._citation_depth > 0: + self.citation_parts.append(data) + if self._year_depth > 0: + self.year_parts.append(data) + if self._gray_stack: + self._gray_stack[-1]["parts"].append(data) + + def handle_endtag(self, tag: str) -> None: + if self._title_depth > 0: + self._title_depth -= 1 + if self._citation_depth > 0: + self._citation_depth -= 1 + if self._year_depth > 0: + self._year_depth -= 1 + if self._gray_stack: + self._gray_stack[-1]["depth"] -= 1 + if self._gray_stack[-1]["depth"] == 0: + text_value = normalize_space("".join(self._gray_stack[-1]["parts"])) + if text_value: + self.gray_texts.append(text_value) + self._gray_stack.pop() + + +def extract_rows(html: str) -> list[str]: + pattern = re.compile( + r"]*\bclass\s*=\s*['\"][^'\"]*\bgsc_a_tr\b[^'\"]*['\"])[^>]*>(.*?)", + re.I | re.S, + ) + return [match.group(1) for match in pattern.finditer(html)] + + +def parse_cluster_id_from_href(href: str | None) -> str | None: + if not href: + return None + parsed = urlparse(href) + query = parse_qs(parsed.query) + + citation_for_view = query.get("citation_for_view") + if citation_for_view: + token = citation_for_view[0].strip() + if token: + return f"cfv:{token}" + + cluster = query.get("cluster") + if cluster: + token = cluster[0].strip() + if token: + return f"cluster:{token}" + return None + + +def parse_year(parts: list[str]) -> int | None: + text = normalize_space(" ".join(parts)) + match = re.search(r"\b(19|20)\d{2}\b", text) + if not match: + return None + try: + return int(match.group(0)) + except ValueError: + return None + + +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) + if not digits: + return None + return int(digits) + + +def _parse_publication_row(row_html: str) -> tuple[PublicationCandidate | None, list[str]]: + parser = ScholarRowParser() + parser.feed(row_html) + warnings: list[str] = [] + title = normalize_space("".join(parser.title_parts)) + if not title: + warnings.append("row_missing_title") + return None, warnings + if not parser.title_href: + warnings.append("row_missing_title_href") + + citation_text = normalize_space(" ".join(parser.citation_parts)) + citation_count = parse_citation_count(parser.citation_parts) + if citation_text and citation_count is None: + warnings.append("layout_row_citation_unparseable") + + year_text = normalize_space(" ".join(parser.year_parts)) + year = parse_year(parser.year_parts) + if year_text and year is None: + warnings.append("layout_row_year_unparseable") + + authors_text = parser.gray_texts[0] if len(parser.gray_texts) > 0 else None + venue_text = parser.gray_texts[1] if len(parser.gray_texts) > 1 else None + return ( + PublicationCandidate( + title=title, + title_url=parser.title_href, + cluster_id=parse_cluster_id_from_href(parser.title_href), + year=year, + citation_count=citation_count, + authors_text=authors_text, + venue_text=venue_text, + pdf_url=None, + ), + warnings, + ) + + +def parse_publications(html: str) -> tuple[list[PublicationCandidate], list[str]]: + rows = extract_rows(html) + warnings: list[str] = [] + publications: list[PublicationCandidate] = [] + + for row_html in rows: + publication, row_warnings = _parse_publication_row(row_html) + warnings.extend(row_warnings) + if publication is None: + continue + publications.append(publication) + + if not rows: + warnings.append("no_rows_detected") + if rows and not publications: + warnings.append("layout_all_rows_unparseable") + + return publications, sorted(set(warnings)) + + +def extract_profile_name(html: str) -> str | None: + pattern = re.compile( + r"<[^>]*\bid\s*=\s*['\"]gsc_prf_in['\"][^>]*>(.*?)]+>", + re.I | re.S, + ) + match = pattern.search(html) + if not match: + return None + value = strip_tags(match.group(1)) + return value or None + + +def extract_profile_image_url(html: str) -> str | None: + og_image_pattern = re.compile( + r"]+property=['\"]og:image['\"][^>]+content=['\"]([^'\"]+)['\"][^>]*>", + re.I | re.S, + ) + og_match = og_image_pattern.search(html) + if og_match: + value = normalize_space(og_match.group(1)) + absolute = build_absolute_scholar_url(value) + if absolute: + return absolute + + image_pattern = re.compile( + r"]*\bid=['\"]gsc_prf_pup-img['\"][^>]*\bsrc=['\"]([^'\"]+)['\"][^>]*>", + re.I | re.S, + ) + image_match = image_pattern.search(html) + if not image_match: + return None + + value = normalize_space(image_match.group(1)) + return build_absolute_scholar_url(value) + + +def extract_articles_range(html: str) -> str | None: + pattern = re.compile( + r"<[^>]*\bid\s*=\s*['\"]gsc_a_nn['\"][^>]*>(.*?)]+>", + re.I | re.S, + ) + match = pattern.search(html) + if not match: + return None + value = strip_tags(match.group(1)) + return value or None + + +def has_show_more_button(html: str) -> bool: + match = SHOW_MORE_BUTTON_RE.search(html) + if match is None: + return False + + button_tag = match.group(0).lower() + if "disabled" in button_tag: + 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 + + +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: + return False + return "can't perform the operation now" in lowered or "cannot perform the operation now" in lowered + + +def count_markers(html: str) -> dict[str, int]: + lowered = html.lower() + return {key: lowered.count(key.lower()) for key in MARKER_KEYS} diff --git a/build/lib/app/services/domains/scholar/rate_limit.py b/build/lib/app/services/domains/scholar/rate_limit.py new file mode 100644 index 0000000..947c91f --- /dev/null +++ b/build/lib/app/services/domains/scholar/rate_limit.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import asyncio +import time + +_REQUEST_LOCK = asyncio.Lock() +_LAST_REQUEST_AT = 0.0 + + +async def wait_for_scholar_slot(*, min_interval_seconds: float) -> None: + global _LAST_REQUEST_AT + interval = max(float(min_interval_seconds), 0.0) + async with _REQUEST_LOCK: + elapsed = time.monotonic() - _LAST_REQUEST_AT + remaining = interval - elapsed + if remaining > 0: + await asyncio.sleep(remaining) + _LAST_REQUEST_AT = time.monotonic() diff --git a/build/lib/app/services/domains/scholar/source.py b/build/lib/app/services/domains/scholar/source.py new file mode 100644 index 0000000..92c6702 --- /dev/null +++ b/build/lib/app/services/domains/scholar/source.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import asyncio +import logging +import random +from dataclasses import dataclass +from typing import Protocol +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +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 (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" + ), +] + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class FetchResult: + requested_url: str + status_code: int | None + final_url: str | None + body: str + error: str | None + + +class ScholarSource(Protocol): + async def fetch_profile_html(self, scholar_id: str) -> FetchResult: + ... + + async def fetch_profile_page_html( + self, + scholar_id: str, + *, + cstart: int, + pagesize: int, + ) -> FetchResult: + ... + + async def fetch_author_search_html( + self, + query: str, + *, + start: int, + ) -> FetchResult: + ... + + +class LiveScholarSource: + def __init__( + self, + *, + timeout_seconds: float = 25.0, + user_agents: list[str] | None = None, + ) -> None: + self._timeout_seconds = timeout_seconds + self._user_agents = user_agents or DEFAULT_USER_AGENTS + + async def fetch_profile_html(self, scholar_id: str) -> FetchResult: + return await self.fetch_profile_page_html( + scholar_id, + cstart=0, + pagesize=DEFAULT_PAGE_SIZE, + ) + + async def fetch_profile_page_html( + self, + scholar_id: str, + *, + cstart: int, + pagesize: int = DEFAULT_PAGE_SIZE, + ) -> FetchResult: + requested_url = _build_profile_url( + scholar_id=scholar_id, + 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) + + async def fetch_author_search_html( + self, + query: str, + *, + start: int = 0, + ) -> FetchResult: + requested_url = _build_author_search_url( + 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 asyncio.to_thread(self._fetch_sync, requested_url) + + async def fetch_publication_html(self, publication_url: str) -> FetchResult: + logger.debug( + "scholar_source.publication_fetch_started", + extra={ + "event": "scholar_source.publication_fetch_started", + "requested_url": publication_url, + }, + ) + return await asyncio.to_thread(self._fetch_sync, publication_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", + }, + ) + + @staticmethod + def _http_error_body(exc: HTTPError) -> str: + try: + return exc.read().decode("utf-8", errors="replace") + except Exception: + return "" + + @staticmethod + def _network_error_result(requested_url: str, exc: URLError) -> FetchResult: + logger.warning( + "scholar_source.fetch_network_error", + extra={"event": "scholar_source.fetch_network_error", "requested_url": requested_url}, + ) + return FetchResult( + requested_url=requested_url, + status_code=None, + final_url=None, + body="", + error=str(exc), + ) + + @staticmethod + def _http_error_result(requested_url: str, exc: HTTPError) -> FetchResult: + logger.warning( + "scholar_source.fetch_http_error", + extra={ + "event": "scholar_source.fetch_http_error", + "requested_url": requested_url, + "status_code": exc.code, + }, + ) + return FetchResult( + requested_url=requested_url, + status_code=exc.code, + final_url=exc.geturl(), + body=LiveScholarSource._http_error_body(exc), + error=str(exc), + ) + + @staticmethod + def _success_result(requested_url: str, response) -> FetchResult: + body = response.read().decode("utf-8", errors="replace") + status_code = getattr(response, "status", 200) + logger.debug( + "scholar_source.fetch_succeeded", + extra={ + "event": "scholar_source.fetch_succeeded", + "requested_url": requested_url, + "status_code": status_code, + }, + ) + return FetchResult( + requested_url=requested_url, + status_code=status_code, + final_url=response.geturl(), + body=body, + error=None, + ) + + def _fetch_sync(self, requested_url: str) -> FetchResult: + request = self._build_request(requested_url) + + try: + with urlopen(request, timeout=self._timeout_seconds) as response: + return self._success_result(requested_url, response) + except HTTPError as exc: + return self._http_error_result(requested_url, exc) + except URLError as exc: + return self._network_error_result(requested_url, exc) + + +def _build_profile_url(*, scholar_id: str, cstart: int, pagesize: int) -> str: + query: dict[str, int | str] = {"hl": "en", "user": scholar_id} + if cstart > 0: + query["cstart"] = int(cstart) + if pagesize > 0: + query["pagesize"] = int(pagesize) + return f"{SCHOLAR_PROFILE_URL}?{urlencode(query)}" + + +def _build_author_search_url(*, query: str, start: int) -> str: + params: dict[str, int | str] = { + "hl": "en", + "view_op": "search_authors", + "mauthors": query, + } + if start > 0: + params["astart"] = int(start) + return f"{SCHOLAR_PROFILE_URL}?{urlencode(params)}" diff --git a/build/lib/app/services/domains/scholar/state_detection.py b/build/lib/app/services/domains/scholar/state_detection.py new file mode 100644 index 0000000..cdc8780 --- /dev/null +++ b/build/lib/app/services/domains/scholar/state_detection.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from app.services.domains.scholar.parser_constants import ( + BLOCKED_KEYWORDS, + NETWORK_DNS_ERROR_KEYWORDS, + NETWORK_TIMEOUT_KEYWORDS, + NETWORK_TLS_ERROR_KEYWORDS, + 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 + + +def classify_network_error_reason(fetch_error: str | None) -> str: + lowered = (fetch_error or "").lower() + if lowered: + if any(keyword in lowered for keyword in NETWORK_DNS_ERROR_KEYWORDS): + return "network_dns_resolution_failed" + if any(keyword in lowered for keyword in NETWORK_TIMEOUT_KEYWORDS): + return "network_timeout" + if any(keyword in lowered for keyword in NETWORK_TLS_ERROR_KEYWORDS): + return "network_tls_error" + if "connection reset" in lowered: + return "network_connection_reset" + if "connection refused" in lowered: + return "network_connection_refused" + if "network is unreachable" in lowered: + return "network_unreachable" + return "network_error_missing_status_code" + + +def classify_block_or_captcha_reason( + *, + status_code: int, + final_url: str, + body_lowered: str, +) -> 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 "not a robot" in body_lowered: + return "blocked_not_a_robot_challenge" + if "recaptcha" in body_lowered: + return "blocked_recaptcha_challenge" + if "captcha" in body_lowered: + return "blocked_captcha_challenge" + if any(keyword in body_lowered for keyword in BLOCKED_KEYWORDS): + return "blocked_keyword_detected" + return None + + +def _warnings_contain(warnings: list[str], code: str) -> bool: + return any(item == code for item in warnings) + + +def _has_layout_row_failure(marker_counts: dict[str, int], warnings: list[str]) -> bool: + if _warnings_contain(warnings, "layout_all_rows_unparseable"): + return True + if marker_counts.get("gsc_a_tr", 0) <= 0: + return False + if _warnings_contain(warnings, "row_missing_title"): + return True + return marker_counts.get("gsc_a_at", 0) <= 0 + + +def _first_layout_warning(warnings: list[str]) -> str | None: + for warning in warnings: + if warning.startswith("layout_"): + return warning + return None + + +def detect_state( + fetch_result: FetchResult, + publications: list[PublicationCandidate], + marker_counts: dict[str, int], + *, + warnings: list[str], + has_show_more_button_flag: bool, + articles_range: str | None, + visible_text: str, +) -> tuple[ParseState, str]: + if fetch_result.status_code is None: + return ParseState.NETWORK_ERROR, classify_network_error_reason(fetch_result.error) + + lowered = fetch_result.body.lower() + final = (fetch_result.final_url or "").lower() + status_code = int(fetch_result.status_code) + + block_reason = classify_block_or_captcha_reason( + status_code=status_code, + final_url=final, + body_lowered=lowered, + ) + if block_reason is not None: + return ParseState.BLOCKED_OR_CAPTCHA, block_reason + + if not publications and any(keyword in visible_text for keyword in NO_RESULTS_KEYWORDS): + return ParseState.NO_RESULTS, "no_results_keyword_detected" + + layout_warning = _first_layout_warning(warnings) + if layout_warning is not None: + return ParseState.LAYOUT_CHANGED, layout_warning + + if _has_layout_row_failure(marker_counts, warnings): + return ParseState.LAYOUT_CHANGED, "layout_publication_rows_unparseable" + + if has_show_more_button_flag and not articles_range: + return ParseState.LAYOUT_CHANGED, "layout_show_more_without_articles_range" + + if not publications: + has_profile_markers = marker_counts.get("gsc_prf_in", 0) > 0 + has_table_markers = marker_counts.get("gsc_a_tr", 0) > 0 or marker_counts.get("gsc_a_at", 0) > 0 + if not has_profile_markers and not has_table_markers: + return ParseState.LAYOUT_CHANGED, "layout_markers_missing" + return ParseState.OK, "no_rows_with_known_markers" + + return ParseState.OK, "publications_extracted" + + +def detect_author_search_state( + fetch_result: FetchResult, + candidates: list[ScholarSearchCandidate], + marker_counts: dict[str, int], + *, + visible_text: str, +) -> tuple[ParseState, str]: + if fetch_result.status_code is None: + return ParseState.NETWORK_ERROR, classify_network_error_reason(fetch_result.error) + + lowered = fetch_result.body.lower() + final = (fetch_result.final_url or "").lower() + status_code = int(fetch_result.status_code) + + block_reason = classify_block_or_captcha_reason( + status_code=status_code, + final_url=final, + body_lowered=lowered, + ) + if block_reason is not None: + return ParseState.BLOCKED_OR_CAPTCHA, block_reason + + if not candidates and any(keyword in visible_text for keyword in NO_AUTHOR_RESULTS_KEYWORDS): + return ParseState.NO_RESULTS, "no_results_keyword_detected" + + if not candidates: + has_search_markers = marker_counts.get("gsc_1usr", 0) > 0 or marker_counts.get("gs_ai_name", 0) > 0 + if not has_search_markers: + return ParseState.NO_RESULTS, "no_search_candidates_detected" + return ParseState.LAYOUT_CHANGED, "layout_author_candidates_unparseable" + + return ParseState.OK, "author_candidates_extracted" diff --git a/build/lib/app/services/domains/scholars/__init__.py b/build/lib/app/services/domains/scholars/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/build/lib/app/services/domains/scholars/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/build/lib/app/services/domains/scholars/application.py b/build/lib/app/services/domains/scholars/application.py new file mode 100644 index 0000000..8da041f --- /dev/null +++ b/build/lib/app/services/domains/scholars/application.py @@ -0,0 +1,974 @@ +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/build/lib/app/services/domains/scholars/constants.py b/build/lib/app/services/domains/scholars/constants.py new file mode 100644 index 0000000..7ee18ad --- /dev/null +++ b/build/lib/app/services/domains/scholars/constants.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import re + +SCHOLAR_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{12}$") +MAX_IMAGE_URL_LENGTH = 2048 +MAX_AUTHOR_SEARCH_LIMIT = 25 + +DEFAULT_AUTHOR_SEARCH_CACHE_MAX_ENTRIES = 512 +DEFAULT_AUTHOR_SEARCH_BLOCKED_CACHE_TTL_SECONDS = 300 +DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD = 1 +DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS = 1800 +DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS = 3.0 +DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS = 1.0 +DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD = 2 +DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD = 3 + +AUTHOR_SEARCH_RUNTIME_STATE_KEY = "global" +AUTHOR_SEARCH_LOCK_NAMESPACE = 3901 +AUTHOR_SEARCH_LOCK_KEY = 1 + +ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", +} + +SEARCH_DISABLED_REASON = "search_disabled_by_configuration" +SEARCH_COOLDOWN_REASON = "search_temporarily_disabled_due_to_repeated_blocks" +SEARCH_CACHED_BLOCK_REASON = "search_temporarily_disabled_from_cached_blocked_response" + +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." + ), + 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." + ), + "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." + ), + "blocked_unusual_traffic_detected": ( + "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." + ), +} diff --git a/build/lib/app/services/domains/scholars/exceptions.py b/build/lib/app/services/domains/scholars/exceptions.py new file mode 100644 index 0000000..f1383e3 --- /dev/null +++ b/build/lib/app/services/domains/scholars/exceptions.py @@ -0,0 +1,5 @@ +from __future__ import annotations + + +class ScholarServiceError(ValueError): + """Raised for expected scholar-management validation failures.""" diff --git a/build/lib/app/services/domains/scholars/search_hints.py b/build/lib/app/services/domains/scholars/search_hints.py new file mode 100644 index 0000000..f905c14 --- /dev/null +++ b/build/lib/app/services/domains/scholars/search_hints.py @@ -0,0 +1,67 @@ +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 ( + MAX_AUTHOR_SEARCH_LIMIT, + STATE_REASON_HINTS, +) + + +def resolve_profile_image( + profile: ScholarProfile, + *, + uploaded_image_url: str | None, +) -> tuple[str | None, str]: + if profile.profile_image_upload_path and uploaded_image_url: + return uploaded_image_url, "upload" + if profile.profile_image_override_url: + return profile.profile_image_override_url, "override" + if profile.profile_image_url: + return profile.profile_image_url, "scraped" + return None, "none" + + +def scrape_state_hint(*, state: ParseState, state_reason: str) -> str | None: + if state not in {ParseState.NETWORK_ERROR, ParseState.BLOCKED_OR_CAPTCHA}: + return None + return STATE_REASON_HINTS.get(state_reason) + + +def _merge_warnings(base: list[str], extra: list[str]) -> list[str]: + if not extra: + return sorted(set(base)) + return sorted(set(base + extra)) + + +def _trim_author_search_result( + parsed: ParsedAuthorSearchPage, + *, + limit: int, + extra_warnings: list[str] | None = None, + state_reason_override: str | None = None, +) -> ParsedAuthorSearchPage: + bounded_limit = max(1, min(int(limit), MAX_AUTHOR_SEARCH_LIMIT)) + return ParsedAuthorSearchPage( + state=parsed.state, + state_reason=state_reason_override or parsed.state_reason, + candidates=parsed.candidates[:bounded_limit], + marker_counts=parsed.marker_counts, + warnings=_merge_warnings(parsed.warnings, extra_warnings or []), + ) + + +def _policy_blocked_author_search_result( + *, + reason: str, + warning_codes: list[str], + limit: int, +) -> ParsedAuthorSearchPage: + _ = limit + return ParsedAuthorSearchPage( + state=ParseState.BLOCKED_OR_CAPTCHA, + state_reason=reason, + candidates=[], + marker_counts={}, + warnings=_merge_warnings([], warning_codes), + ) diff --git a/build/lib/app/services/domains/scholars/uploads.py b/build/lib/app/services/domains/scholars/uploads.py new file mode 100644 index 0000000..c33fd47 --- /dev/null +++ b/build/lib/app/services/domains/scholars/uploads.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path + +from app.services.domains.scholars.exceptions import ScholarServiceError + + +def _ensure_upload_root(upload_dir: str, *, create: bool) -> Path: + root = Path(upload_dir).expanduser().resolve() + if create: + root.mkdir(parents=True, exist_ok=True) + return root + + +def _resolve_upload_path(upload_root: Path, relative_path: str) -> Path: + candidate = (upload_root / relative_path).resolve() + if upload_root != candidate and upload_root not in candidate.parents: + raise ScholarServiceError("Invalid scholar image path.") + return candidate + + +def _safe_remove_upload(upload_root: Path, relative_path: str | None) -> None: + if not relative_path: + return + try: + file_path = _resolve_upload_path(upload_root, relative_path) + except ScholarServiceError: + return + + try: + if file_path.exists() and file_path.is_file(): + file_path.unlink() + except OSError: + return + + +def resolve_upload_file_path(*, upload_dir: str, relative_path: str) -> Path: + root = _ensure_upload_root(upload_dir, create=False) + return _resolve_upload_path(root, relative_path) diff --git a/build/lib/app/services/domains/scholars/validators.py b/build/lib/app/services/domains/scholars/validators.py new file mode 100644 index 0000000..1c79baa --- /dev/null +++ b/build/lib/app/services/domains/scholars/validators.py @@ -0,0 +1,38 @@ +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 + + +def validate_scholar_id(value: str) -> str: + scholar_id = value.strip() + if not SCHOLAR_ID_PATTERN.fullmatch(scholar_id): + raise ScholarServiceError("Scholar ID must match [a-zA-Z0-9_-]{12}.") + return scholar_id + + +def normalize_display_name(value: str) -> str | None: + normalized = value.strip() + return normalized if normalized else None + + +def normalize_profile_image_url(value: str | None) -> str | None: + if value is None: + return None + + candidate = value.strip() + if not candidate: + return None + + if len(candidate) > MAX_IMAGE_URL_LENGTH: + 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: + raise ScholarServiceError("Image URL must be an absolute http(s) URL.") + + return candidate diff --git a/build/lib/app/services/domains/settings/__init__.py b/build/lib/app/services/domains/settings/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/build/lib/app/services/domains/settings/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/build/lib/app/services/domains/settings/application.py b/build/lib/app/services/domains/settings/application.py new file mode 100644 index 0000000..7f435e8 --- /dev/null +++ b/build/lib/app/services/domains/settings/application.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import UserSetting + + +class UserSettingsServiceError(ValueError): + """Raised for expected settings-validation failures.""" + + +NAV_PAGE_DASHBOARD = "dashboard" +NAV_PAGE_SCHOLARS = "scholars" +NAV_PAGE_PUBLICATIONS = "publications" +NAV_PAGE_SETTINGS = "settings" +NAV_PAGE_STYLE_GUIDE = "style-guide" +NAV_PAGE_RUNS = "runs" +NAV_PAGE_USERS = "users" + +ALLOWED_NAV_PAGES = ( + NAV_PAGE_DASHBOARD, + NAV_PAGE_SCHOLARS, + NAV_PAGE_PUBLICATIONS, + NAV_PAGE_SETTINGS, + NAV_PAGE_STYLE_GUIDE, + NAV_PAGE_RUNS, + NAV_PAGE_USERS, +) +REQUIRED_NAV_PAGES = ( + NAV_PAGE_DASHBOARD, + NAV_PAGE_SCHOLARS, + NAV_PAGE_SETTINGS, +) +DEFAULT_NAV_VISIBLE_PAGES = list(ALLOWED_NAV_PAGES) +HARD_MIN_RUN_INTERVAL_MINUTES = 15 +HARD_MIN_REQUEST_DELAY_SECONDS = 2 + + +def resolve_run_interval_minimum(configured_minimum: int | None) -> int: + try: + parsed = int(configured_minimum) if configured_minimum is not None else HARD_MIN_RUN_INTERVAL_MINUTES + except (TypeError, ValueError): + parsed = HARD_MIN_RUN_INTERVAL_MINUTES + return max(HARD_MIN_RUN_INTERVAL_MINUTES, parsed) + + +def resolve_request_delay_minimum(configured_minimum: int | None) -> int: + try: + parsed = int(configured_minimum) if configured_minimum is not None else HARD_MIN_REQUEST_DELAY_SECONDS + except (TypeError, ValueError): + parsed = HARD_MIN_REQUEST_DELAY_SECONDS + return max(HARD_MIN_REQUEST_DELAY_SECONDS, parsed) + + +def parse_run_interval_minutes(value: str, *, minimum: int = HARD_MIN_RUN_INTERVAL_MINUTES) -> int: + try: + parsed = int(value) + except ValueError as exc: + 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." + ) + return parsed + + +def parse_request_delay_seconds(value: str, *, minimum: int = HARD_MIN_REQUEST_DELAY_SECONDS) -> int: + try: + parsed = int(value) + except ValueError as exc: + 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." + ) + return parsed + + +def parse_nav_visible_pages(value: object) -> list[str]: + if not isinstance(value, list): + raise UserSettingsServiceError("Navigation visibility must be a list of page ids.") + + deduped: list[str] = [] + seen: set[str] = set() + + for raw_page in value: + if not isinstance(raw_page, str): + raise UserSettingsServiceError("Navigation visibility entries must be strings.") + + page_id = raw_page.strip() + if page_id not in ALLOWED_NAV_PAGES: + raise UserSettingsServiceError(f"Unsupported navigation page id: {page_id}") + + if page_id in seen: + continue + + seen.add(page_id) + deduped.append(page_id) + + 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." + ) + + return deduped + + +from app.settings import settings as app_settings + +async def get_or_create_settings( + db_session: AsyncSession, + *, + user_id: int, +) -> UserSetting: + 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, + 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) + return settings + + +async def update_settings( + db_session: AsyncSession, + *, + settings: UserSetting, + auto_run_enabled: bool, + 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/build/lib/app/services/domains/unpaywall/__init__.py b/build/lib/app/services/domains/unpaywall/__init__.py new file mode 100644 index 0000000..a1baa12 --- /dev/null +++ b/build/lib/app/services/domains/unpaywall/__init__.py @@ -0,0 +1,10 @@ +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 + + return await _impl(*args, **kwargs) + + +__all__ = ["resolve_publication_pdf_urls"] diff --git a/build/lib/app/services/domains/unpaywall/application.py b/build/lib/app/services/domains/unpaywall/application.py new file mode 100644 index 0000000..13104e9 --- /dev/null +++ b/build/lib/app/services/domains/unpaywall/application.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +from dataclasses import dataclass +import logging +import re +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 ( + looks_like_pdf_url, + resolve_pdf_from_landing_page, +) +from app.services.domains.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 + +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) +DOI_URL_RE = re.compile(r"(?:https?://)?(?:dx\.)?doi\.org/(10\.\d{4,9}/[-._;()/:A-Z0-9]+)", re.I) +UNPAYWALL_URL_TEMPLATE = "https://api.unpaywall.org/v2/{doi}" +FAILURE_MISSING_DOI = "missing_doi" +FAILURE_NO_RECORD = "no_unpaywall_record" +FAILURE_NO_PDF = "no_pdf_found" +FAILURE_RESOLUTION_EXCEPTION = "resolution_exception" +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class OaResolutionOutcome: + publication_id: int + doi: str | None + pdf_url: str | None + failure_reason: str | None + source: str | None + used_crossref: bool + + +def _extract_doi_candidate(text: str | None) -> str | None: + if not text: + return None + decoded = unquote(text) + match = DOI_PATTERN.search(decoded) + if not match: + return None + return match.group(0).rstrip(" .;,)") + + +def _extract_explicit_doi(text: str | None) -> str | None: + if not text: + return None + decoded = unquote(text) + url_match = DOI_URL_RE.search(decoded) + if url_match: + return normalize_doi(url_match.group(1)) + prefix_match = DOI_PREFIX_RE.search(decoded) + if prefix_match: + return normalize_doi(prefix_match.group(1)) + return None + + +def _publication_doi(item: PublicationListItem | UnreadPublicationItem) -> str | None: + stored = None + if getattr(item, "display_identifier", None) and item.display_identifier.kind == "doi": + stored = normalize_doi(item.display_identifier.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 stored + + +def _payload_locations(payload: dict) -> list[dict]: + locations: list[dict] = [] + best = payload.get("best_oa_location") + if isinstance(best, dict): + locations.append(best) + oa_locations = payload.get("oa_locations") + if isinstance(oa_locations, list): + locations.extend(location for location in oa_locations if isinstance(location, dict)) + return locations + + +def _location_value(location: dict, key: str) -> str | None: + value = location.get(key) + if not isinstance(value, str): + return None + normalized = value.strip() + return normalized or None + + +def _payload_pdf_candidates(payload: dict) -> list[str]: + candidates: list[str] = [] + seen: set[str] = set() + for location in _payload_locations(payload): + candidate = _location_value(location, "url_for_pdf") + if candidate is None or candidate in seen: + continue + seen.add(candidate) + candidates.append(candidate) + return candidates + + +def _payload_landing_candidates(payload: dict) -> list[str]: + candidates: list[str] = [] + seen: set[str] = set() + for location in _payload_locations(payload): + candidate = _location_value(location, "url") + if candidate is None or candidate in seen: + continue + seen.add(candidate) + candidates.append(candidate) + return candidates + + +def _crawl_targets( + *, + payload: dict, + pdf_candidates: list[str], +) -> list[str]: + targets = _payload_landing_candidates(payload) + seen = set(targets) + for candidate in pdf_candidates: + if candidate in seen: + continue + targets.append(candidate) + seen.add(candidate) + doi = normalize_doi(str(payload.get("doi") or "")) + doi_landing_url = f"https://doi.org/{doi}" if doi else None + if doi_landing_url and doi_landing_url not in seen: + targets.append(doi_landing_url) + return targets + + +def _has_direct_payload_pdf(payload: dict) -> bool: + return any(looks_like_pdf_url(candidate) for candidate in _payload_pdf_candidates(payload)) + + +async def _resolved_pdf_url_from_payload( + payload: dict, + *, + client, +) -> str | None: + pdf_candidates = _payload_pdf_candidates(payload) + for candidate in pdf_candidates: + if looks_like_pdf_url(candidate): + return candidate + 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}, + ) + return discovered + return None + + +async def _fetch_unpaywall_payload_by_doi( + *, + client, + doi: str, + 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 + payload = response.json() + if not isinstance(payload, dict): + return None + return payload + + +def _email_for_request(request_email: str | None) -> str | None: + email = (request_email or "").strip() or settings.unpaywall_email.strip() + 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, 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, doi + if not allow_crossref or not settings.crossref_enabled: + 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, 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, crossref_doi + return payload, True, crossref_doi + + +async def _doi_and_pdf_from_payload( + payload: dict, + *, + client, +) -> tuple[str | None, str | None]: + doi = normalize_doi(str(payload.get("doi") or "")) + return doi, await _resolved_pdf_url_from_payload(payload, client=client) + + +def _resolution_targets(items: list[PublicationListItem]) -> list[PublicationListItem]: + return [item for item in items if not item.pdf_url] + + +def _crossref_budget_value() -> int: + return max(int(settings.crossref_max_lookups_per_request), 0) + + +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=normalize_doi(doi_override) if doi_override is not None else _publication_doi(item), + pdf_url=None, + failure_reason=failure_reason, + source=None, + used_crossref=used_crossref, + ) + + +def _missing_payload_failure_reason(item: PublicationListItem, *, used_crossref: bool) -> str: + if _publication_doi(item): + return FAILURE_NO_RECORD + if used_crossref: + return FAILURE_NO_RECORD + return FAILURE_MISSING_DOI + + +def _source_name(*, used_crossref: bool) -> str: + return "crossref+unpaywall" if used_crossref else "unpaywall" + + +def _outcome_from_payload( + *, + item: PublicationListItem, + doi: str | None, + pdf_url: str | None, + used_crossref: bool, +) -> OaResolutionOutcome: + return OaResolutionOutcome( + publication_id=item.publication_id, + doi=doi, + pdf_url=pdf_url, + failure_reason=None if pdf_url else FAILURE_NO_PDF, + source=_source_name(used_crossref=used_crossref), + used_crossref=used_crossref, + ) + + +def _resolved_pdf_count(outcomes: dict[int, OaResolutionOutcome]) -> int: + return sum(1 for outcome in outcomes.values() if outcome.pdf_url) + + +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() + } + + +async def _resolve_outcome_for_item( + *, + client, + item: PublicationListItem, + email: str, + allow_crossref: bool, +) -> OaResolutionOutcome: + payload, used_crossref, resolved_doi = await _resolve_item_payload( + client=client, + item=item, + email=email, + allow_crossref=allow_crossref, + ) + if not isinstance(payload, dict): + return _outcome_with_failure( + 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( + item=item, + doi=doi, + pdf_url=pdf_url, + used_crossref=used_crossref, + ) + + +def _doi_input_count(items: list[PublicationListItem]) -> int: + return len([item for item in items if _publication_doi(item)]) + + +def _search_attempt_count(*, targets: list[PublicationListItem]) -> int: + return len([item for item in targets if not _publication_doi(item)]) + + +async def _safe_outcome_for_item( + *, + client, + item: PublicationListItem, + email: str, + allow_crossref: bool, +) -> OaResolutionOutcome: + try: + return await _resolve_outcome_for_item( + client=client, + item=item, + email=email, + 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), + }, + ) + return _outcome_with_failure( + item=item, + failure_reason=FAILURE_RESOLUTION_EXCEPTION, + used_crossref=allow_crossref and settings.crossref_enabled, + ) + + +async def _resolve_outcomes_with_client( + *, + client, + targets: list[PublicationListItem], + email: str, +) -> dict[int, OaResolutionOutcome]: + outcomes: dict[int, OaResolutionOutcome] = {} + crossref_budget = _crossref_budget_value() + crossref_lookups = 0 + for item in targets: + allow_crossref = crossref_budget > 0 and crossref_lookups < crossref_budget + outcome = await _safe_outcome_for_item( + client=client, + item=item, + email=email, + allow_crossref=allow_crossref, + ) + if outcome.used_crossref: + crossref_lookups += 1 + outcomes[item.publication_id] = outcome + return outcomes + + +async def resolve_publication_oa_metadata( + items: list[PublicationListItem], + *, + request_email: str | None = None, +) -> dict[int, tuple[str | None, str | None]]: + outcomes = await resolve_publication_oa_outcomes(items, request_email=request_email) + return _outcome_metadata(outcomes) + + +async def resolve_publication_oa_outcomes( + items: list[PublicationListItem], + *, + request_email: str | None = None, +) -> dict[int, OaResolutionOutcome]: + if not settings.unpaywall_enabled: + return {} + email = _email_for_request(request_email) + if email is None: + 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)] + 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( + 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, + ) + return outcomes + + +async def resolve_publication_pdf_urls( + items: list[PublicationListItem], + *, + request_email: str | None = None, +) -> dict[int, str | None]: + resolved = await resolve_publication_oa_metadata(items, request_email=request_email) + return {publication_id: pdf for publication_id, (_doi, pdf) in resolved.items()} diff --git a/build/lib/app/services/domains/unpaywall/pdf_discovery.py b/build/lib/app/services/domains/unpaywall/pdf_discovery.py new file mode 100644 index 0000000..5f82a58 --- /dev/null +++ b/build/lib/app/services/domains/unpaywall/pdf_discovery.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from html.parser import HTMLParser +import re +from urllib.parse import urljoin, urlparse + +from app.services.domains.unpaywall.rate_limit import wait_for_unpaywall_slot +from app.settings import settings + +PDF_MIME = "application/pdf" +URL_RE = re.compile(r"https?://[^\s\"'<>]+", re.I) + + +class _LandingPdfParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.base_href: str | None = None + self.candidates: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attrs_map = {key.lower(): (value or "").strip() for key, value in attrs} + if tag == "base": + self.base_href = attrs_map.get("href") or self.base_href + return + if tag == "meta": + self._append_meta_candidate(attrs_map) + return + if tag == "link": + self._append_link_candidate(attrs_map) + return + if tag == "a": + self._append_anchor_candidate(attrs_map) + + def _append_meta_candidate(self, attrs_map: dict[str, str]) -> None: + meta_name = (attrs_map.get("name") or attrs_map.get("property") or "").lower() + if meta_name != "citation_pdf_url": + return + content = attrs_map.get("content") + if content: + self.candidates.append(content) + + def _append_link_candidate(self, attrs_map: dict[str, str]) -> None: + href = attrs_map.get("href") + link_type = (attrs_map.get("type") or "").lower() + if href and "pdf" in link_type: + self.candidates.append(href) + + def _append_anchor_candidate(self, attrs_map: dict[str, str]) -> None: + href = attrs_map.get("href") + if href: + self.candidates.append(href) + + +def looks_like_pdf_url(url: str | None) -> bool: + if not isinstance(url, str): + return False + value = url.strip() + if not value: + return False + parsed = urlparse(value) + path = (parsed.path or "").lower() + query = (parsed.query or "").lower() + return path.endswith(".pdf") or ".pdf" in query + + +def _normalized_candidate_urls(*, page_url: str, html: str) -> list[str]: + parser = _LandingPdfParser() + parser.feed(html) + parser.close() + base_url = urljoin(page_url, parser.base_href) if parser.base_href else page_url + raw_candidates = [*parser.candidates, *_text_url_candidates(html)] + deduped: list[str] = [] + seen: set[str] = set() + for raw in raw_candidates: + absolute = urljoin(base_url, raw.strip()) + parsed = urlparse(absolute) + if parsed.scheme not in {"http", "https"}: + continue + if absolute in seen: + continue + seen.add(absolute) + deduped.append(absolute) + return sorted(deduped, key=_candidate_sort_key) + + +def _text_url_candidates(html: str) -> list[str]: + candidates: list[str] = [] + for match in URL_RE.findall(html): + cleaned = match.rstrip(".,);]}>") + if "pdf" not in cleaned.lower(): + continue + candidates.append(cleaned) + return candidates + + +def _candidate_sort_key(candidate: str) -> tuple[int, str]: + lowered = candidate.lower() + if looks_like_pdf_url(candidate): + return (0, lowered) + if any(token in lowered for token in ("doi", "full", "article", "download")): + return (1, lowered) + return (2, lowered) + + +def _is_html_response(response) -> bool: + content_type = str(response.headers.get("content-type") or "").lower() + return "text/html" in content_type or "application/xhtml+xml" in content_type + + +async def _fetch_page_html(client, *, page_url: str) -> str | None: + await wait_for_unpaywall_slot(min_interval_seconds=settings.unpaywall_min_interval_seconds) + response = await client.get(page_url, follow_redirects=True) + if response.status_code != 200 or not _is_html_response(response): + return None + text = response.text or "" + return text[: max(int(settings.unpaywall_pdf_discovery_max_html_bytes), 0)] + + +async def _candidate_is_pdf(client, *, candidate_url: str) -> bool: + if looks_like_pdf_url(candidate_url): + return True + await wait_for_unpaywall_slot(min_interval_seconds=settings.unpaywall_min_interval_seconds) + response = await client.get(candidate_url, follow_redirects=True) + content_type = str(response.headers.get("content-type") or "").lower() + return response.status_code == 200 and PDF_MIME in content_type + + +def _candidate_limit() -> int: + return max(int(settings.unpaywall_pdf_discovery_max_candidates), 1) + + +async def _resolve_pdf_from_candidate_page(client, *, candidate_url: str) -> str | None: + html = await _fetch_page_html(client, page_url=candidate_url) + if not html: + return None + nested_candidates = _normalized_candidate_urls(page_url=candidate_url, html=html) + for nested in nested_candidates[: _candidate_limit()]: + if await _candidate_is_pdf(client, candidate_url=nested): + return nested + return None + + +async def resolve_pdf_from_landing_page(client, *, page_url: str) -> str | None: + if not settings.unpaywall_pdf_discovery_enabled: + return None + html = await _fetch_page_html(client, page_url=page_url) + if not html: + return None + candidates = _normalized_candidate_urls(page_url=page_url, html=html) + for candidate in candidates[: _candidate_limit()]: + if await _candidate_is_pdf(client, candidate_url=candidate): + return candidate + nested_pdf = await _resolve_pdf_from_candidate_page(client, candidate_url=candidate) + if nested_pdf: + return nested_pdf + return None diff --git a/build/lib/app/services/domains/unpaywall/rate_limit.py b/build/lib/app/services/domains/unpaywall/rate_limit.py new file mode 100644 index 0000000..76d8a3d --- /dev/null +++ b/build/lib/app/services/domains/unpaywall/rate_limit.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import asyncio +import time + +_REQUEST_LOCK = asyncio.Lock() +_LAST_REQUEST_AT = 0.0 + + +async def wait_for_unpaywall_slot(*, min_interval_seconds: float) -> None: + global _LAST_REQUEST_AT + interval = max(float(min_interval_seconds), 0.0) + async with _REQUEST_LOCK: + elapsed = time.monotonic() - _LAST_REQUEST_AT + remaining = interval - elapsed + if remaining > 0: + await asyncio.sleep(remaining) + _LAST_REQUEST_AT = time.monotonic() diff --git a/build/lib/app/services/domains/users/__init__.py b/build/lib/app/services/domains/users/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/build/lib/app/services/domains/users/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/build/lib/app/services/domains/users/application.py b/build/lib/app/services/domains/users/application.py new file mode 100644 index 0000000..7f019c4 --- /dev/null +++ b/build/lib/app/services/domains/users/application.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import re + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import User + +EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +class UserServiceError(ValueError): + """Raised for expected user-management validation failures.""" + + +def normalize_email(value: str) -> str: + return value.strip().lower() + + +def validate_email(value: str) -> str: + email = normalize_email(value) + if not EMAIL_PATTERN.fullmatch(email): + raise UserServiceError("Enter a valid email address.") + return email + + +def validate_password(value: str) -> str: + password = value.strip() + if len(password) < 8: + raise UserServiceError("Password must be at least 8 characters.") + return password + + +async def get_user_by_id(db_session: AsyncSession, user_id: int) -> User | None: + result = await db_session.execute(select(User).where(User.id == user_id)) + return result.scalar_one_or_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)) + ) + return result.scalar_one_or_none() + + +async def list_users(db_session: AsyncSession) -> list[User]: + result = await db_session.execute(select(User).order_by(User.email.asc())) + return list(result.scalars().all()) + + +async def create_user( + db_session: AsyncSession, + *, + email: str, + password_hash: str, + is_admin: bool, +) -> User: + user = User( + email=validate_email(email), + password_hash=password_hash, + is_admin=is_admin, + is_active=True, + ) + db_session.add(user) + try: + await db_session.commit() + except IntegrityError as exc: + await db_session.rollback() + raise UserServiceError("A user with that email already exists.") from exc + await db_session.refresh(user) + return user + + +async def set_user_active( + db_session: AsyncSession, + *, + user: User, + is_active: bool, +) -> User: + user.is_active = is_active + await db_session.commit() + await db_session.refresh(user) + return user + + +async def set_user_password_hash( + db_session: AsyncSession, + *, + user: User, + password_hash: str, +) -> User: + user.password_hash = password_hash + await db_session.commit() + await db_session.refresh(user) + return user + diff --git a/build/lib/app/settings.py b/build/lib/app/settings.py new file mode 100644 index 0000000..f56ff51 --- /dev/null +++ b/build/lib/app/settings.py @@ -0,0 +1,267 @@ +from dataclasses import dataclass +import os + +DEFAULT_SECURITY_PERMISSIONS_POLICY = ( + "accelerometer=(), autoplay=(), camera=(), display-capture=(), " + "geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()" +) +DEFAULT_SECURITY_CSP_POLICY = ( + "default-src 'self'; " + "base-uri 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'; " + "img-src 'self' data: https:; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "font-src 'self' data:; " + "connect-src 'self'; " + "object-src 'none'" +) +DEFAULT_SECURITY_CSP_DOCS_POLICY = ( + "default-src 'self'; " + "img-src 'self' data: https:; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "font-src 'self' data:; " + "connect-src 'self' https:; " + "object-src 'none'; " + "frame-ancestors 'none'" +) + + +def _env_bool(name: str, default: bool) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _env_int(name: str, default: int) -> int: + value = os.getenv(name) + if value is None: + return default + return int(value) + + +def _env_float(name: str, default: float) -> float: + value = os.getenv(name) + if value is None: + return default + return float(value) + + +def _env_str(name: str, default: str) -> str: + value = os.getenv(name) + if value is None: + return default + return value.strip() or default + + +@dataclass(frozen=True) +class Settings: + app_name: str = os.getenv("APP_NAME", "scholarr") + database_url: str = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://scholar:scholar@db:5432/scholar", + ) + database_pool_mode: str = _env_str("DATABASE_POOL_MODE", "auto") + database_pool_size: int = _env_int("DATABASE_POOL_SIZE", 5) + database_pool_max_overflow: int = _env_int("DATABASE_POOL_MAX_OVERFLOW", 10) + database_pool_timeout_seconds: int = _env_int("DATABASE_POOL_TIMEOUT_SECONDS", 30) + session_secret_key: str = os.getenv("SESSION_SECRET_KEY", "dev-insecure-session-key") + session_cookie_secure: bool = _env_bool("SESSION_COOKIE_SECURE", False) + security_headers_enabled: bool = _env_bool("SECURITY_HEADERS_ENABLED", True) + security_x_content_type_options: str = _env_str("SECURITY_X_CONTENT_TYPE_OPTIONS", "nosniff") + security_x_frame_options: str = _env_str("SECURITY_X_FRAME_OPTIONS", "DENY") + security_referrer_policy: str = _env_str("SECURITY_REFERRER_POLICY", "strict-origin-when-cross-origin") + security_permissions_policy: str = _env_str( + "SECURITY_PERMISSIONS_POLICY", + DEFAULT_SECURITY_PERMISSIONS_POLICY, + ) + security_cross_origin_opener_policy: str = _env_str( + "SECURITY_CROSS_ORIGIN_OPENER_POLICY", + "same-origin", + ) + security_cross_origin_resource_policy: str = _env_str( + "SECURITY_CROSS_ORIGIN_RESOURCE_POLICY", + "same-origin", + ) + security_csp_enabled: bool = _env_bool("SECURITY_CSP_ENABLED", True) + security_csp_policy: str = _env_str("SECURITY_CSP_POLICY", DEFAULT_SECURITY_CSP_POLICY) + security_csp_docs_policy: str = _env_str("SECURITY_CSP_DOCS_POLICY", DEFAULT_SECURITY_CSP_DOCS_POLICY) + security_csp_report_only: bool = _env_bool("SECURITY_CSP_REPORT_ONLY", False) + security_strict_transport_security_enabled: bool = _env_bool( + "SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED", + False, + ) + security_strict_transport_security_max_age: int = _env_int( + "SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE", + 31_536_000, + ) + security_strict_transport_security_include_subdomains: bool = _env_bool( + "SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS", + True, + ) + security_strict_transport_security_preload: bool = _env_bool( + "SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD", + False, + ) + login_rate_limit_attempts: int = _env_int("LOGIN_RATE_LIMIT_ATTEMPTS", 5) + login_rate_limit_window_seconds: int = _env_int( + "LOGIN_RATE_LIMIT_WINDOW_SECONDS", + 60, + ) + log_level: str = _env_str("LOG_LEVEL", "INFO") + log_format: str = _env_str("LOG_FORMAT", "console") + log_requests: bool = _env_bool("LOG_REQUESTS", True) + log_uvicorn_access: bool = _env_bool("LOG_UVICORN_ACCESS", False) + log_request_skip_paths: str = _env_str("LOG_REQUEST_SKIP_PATHS", "/healthz") + log_redact_fields: str = os.getenv("LOG_REDACT_FIELDS", "") + scheduler_enabled: bool = _env_bool("SCHEDULER_ENABLED", True) + scheduler_tick_seconds: int = _env_int("SCHEDULER_TICK_SECONDS", 60) + ingestion_automation_allowed: bool = _env_bool( + "INGESTION_AUTOMATION_ALLOWED", + True, + ) + ingestion_manual_run_allowed: bool = _env_bool( + "INGESTION_MANUAL_RUN_ALLOWED", + True, + ) + ingestion_min_run_interval_minutes: int = _env_int( + "INGESTION_MIN_RUN_INTERVAL_MINUTES", + 15, + ) + ingestion_min_request_delay_seconds: int = _env_int( + "INGESTION_MIN_REQUEST_DELAY_SECONDS", + 2, + ) + ingestion_network_error_retries: int = _env_int("INGESTION_NETWORK_ERROR_RETRIES", 1) + ingestion_retry_backoff_seconds: float = _env_float( + "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, + ) + ingestion_page_size: int = _env_int("INGESTION_PAGE_SIZE", 100) + ingestion_alert_blocked_failure_threshold: int = _env_int( + "INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD", + 1, + ) + ingestion_alert_network_failure_threshold: int = _env_int( + "INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD", + 2, + ) + ingestion_alert_retry_scheduled_threshold: int = _env_int( + "INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD", + 3, + ) + ingestion_safety_cooldown_blocked_seconds: int = _env_int( + "INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS", + 1800, + ) + ingestion_safety_cooldown_network_seconds: int = _env_int( + "INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS", + 900, + ) + ingestion_continuation_queue_enabled: bool = _env_bool( + "INGESTION_CONTINUATION_QUEUE_ENABLED", + True, + ) + ingestion_continuation_base_delay_seconds: int = _env_int( + "INGESTION_CONTINUATION_BASE_DELAY_SECONDS", + 120, + ) + ingestion_continuation_max_delay_seconds: int = _env_int( + "INGESTION_CONTINUATION_MAX_DELAY_SECONDS", + 3600, + ) + ingestion_continuation_max_attempts: int = _env_int( + "INGESTION_CONTINUATION_MAX_ATTEMPTS", + 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( + "SCHOLAR_IMAGE_UPLOAD_DIR", + "/tmp/scholarr_uploads/scholar_images", + ) + scholar_image_upload_max_bytes: int = _env_int( + "SCHOLAR_IMAGE_UPLOAD_MAX_BYTES", + 2_000_000, + ) + scholar_name_search_enabled: bool = _env_bool("SCHOLAR_NAME_SEARCH_ENABLED", True) + scholar_name_search_cache_ttl_seconds: int = _env_int( + "SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS", + 21_600, + ) + scholar_name_search_blocked_cache_ttl_seconds: int = _env_int( + "SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS", + 300, + ) + scholar_name_search_cache_max_entries: int = _env_int( + "SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES", + 512, + ) + scholar_name_search_min_interval_seconds: float = _env_float( + "SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS", + 8.0, + ) + scholar_name_search_interval_jitter_seconds: float = _env_float( + "SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS", + 2.0, + ) + scholar_name_search_cooldown_block_threshold: int = _env_int( + "SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD", + 1, + ) + scholar_name_search_cooldown_seconds: int = _env_int( + "SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS", + 1800, + ) + scholar_name_search_alert_retry_count_threshold: int = _env_int( + "SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD", + 2, + ) + scholar_name_search_alert_cooldown_rejections_threshold: int = _env_int( + "SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD", + 3, + ) + 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) + unpaywall_min_interval_seconds: float = _env_float("UNPAYWALL_MIN_INTERVAL_SECONDS", 0.6) + unpaywall_max_items_per_request: int = _env_int("UNPAYWALL_MAX_ITEMS_PER_REQUEST", 20) + unpaywall_retry_cooldown_seconds: int = _env_int("UNPAYWALL_RETRY_COOLDOWN_SECONDS", 1800) + pdf_auto_retry_interval_seconds: int = _env_int( + "PDF_AUTO_RETRY_INTERVAL_SECONDS", + 86_400, + ) + pdf_auto_retry_first_interval_seconds: int = _env_int( + "PDF_AUTO_RETRY_FIRST_INTERVAL_SECONDS", + 3_600, + ) + pdf_auto_retry_max_attempts: int = _env_int("PDF_AUTO_RETRY_MAX_ATTEMPTS", 3) + 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) + 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/diag_pubs.py b/diag_pubs.py new file mode 100644 index 0000000..2417001 --- /dev/null +++ b/diag_pubs.py @@ -0,0 +1,39 @@ + +import asyncio +from sqlalchemy import select, func +from app.db.session import get_session_factory +from app.db.models import Publication, ScholarPublication + +async def check_publications(): + session_factory = get_session_factory() + async with session_factory() as session: + # Total publications + total_pubs = await session.execute(select(func.count()).select_from(Publication)) + print(f"Total Publications: {total_pubs.scalar()}") + + # Total scholar links + total_links = await session.execute(select(func.count()).select_from(ScholarPublication)) + print(f"Total Scholar-Publication Links: {total_links.scalar()}") + + # Check for orphans + orphans = await session.execute( + select(func.count()) + .select_from(Publication) + .outerjoin(ScholarPublication, Publication.id == ScholarPublication.publication_id) + .where(ScholarPublication.publication_id == None) + ) + print(f"Orphaned Publications (no link): {orphans.scalar()}") + + # Check sorting behavior + latest_links = await session.execute( + select(Publication.title_raw, ScholarPublication.created_at) + .join(ScholarPublication, Publication.id == ScholarPublication.publication_id) + .order_by(ScholarPublication.created_at.desc()) + .limit(10) + ) + print("\nLatest 10 links by created_at:") + for row in latest_links: + print(f"- {row.created_at}: {row.title_raw[:50]}") + +if __name__ == "__main__": + asyncio.run(check_publications()) diff --git a/docs/developer/api-contract.md b/docs/developer/api-contract.md new file mode 100644 index 0000000..6884bc6 --- /dev/null +++ b/docs/developer/api-contract.md @@ -0,0 +1,12 @@ +# API Contracts + +`Scholarr` is designed with strictly typed Pydantic V2 models, serialized across the network through standard OpenAPI `v3` specs via FastAPI. + +## DTO Models Structure +FastAPI routes dynamically ingest request/response Data Transfer Objects. +- **Example**: `PublicationListItem` no longer contains a hard-coded `.doi`. It contains a `.display_identifier` property resolving the highest confidence identifier regardless of backend origin. +- **Testing**: Run `./scripts/check_frontend_api_contract.py` or equivalent integration steps mapped in CI to ensure that backend python routes strictly map to the TypeScript types compiled by the Frontend. + +## Frontend Contract Requirements +The UI exclusively calls routes exposed by FastAPI's `APIRouter`. +All frontend integration tests enforce that Vue 3 Components do not assume hard properties directly outside the bounded TS schemas. This avoids type mismatch runtime explosions if the database is scaled horizontally. diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index 0a169e6..afa6b05 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -20,6 +20,32 @@ Canonical business logic belongs in `app/services/domains/*`. ## Frontend Behavior Notes -- 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. +- Navigation: Mobile primary nav is in a left drawer and closes on route change or logout. +- Lists: Long list views use internal scroll containers to prevent viewport overflow. +- Rate Limiting: Name search remains intentionally constrained due to upstream anti-bot behavior; production onboarding should prefer scholar ID/profile URLs directly if possible. + +## Data Integration & Acquisition Flow + +```mermaid +graph TD + UI[Frontend Vue App] --> API[FastAPI Backend] + API --> Scheduler[Background Celery/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 +``` + +### Identifier Engine Philosophy +The platform previously treated the `DOI` as a hardcoded 1:1 property of a publication. It now utilizes a decoupled *Identifier Gathering* module (`PublicationIdentifier` table). A single publication can have multiple identifiers (ex. `doi`, `arxiv`, `pmid`, `pmcid`). This creates high resilience when integrating with external APIs, allowing systems like Unpaywall to be fed explicitly with high-confidence DOIs, rather than relying on unstructured search heuristics. diff --git a/docs/developer/ingestion.md b/docs/developer/ingestion.md new file mode 100644 index 0000000..b906f15 --- /dev/null +++ b/docs/developer/ingestion.md @@ -0,0 +1,26 @@ +# Ingestion System & Backoff Strategies + +The `ScholarIngestionService` drives the primary data acquisition loop. Since Google Scholar utilizes heavy bot protection and rate limits, this package contains nuanced backoffs to protect user networks from automated IP bans. + +## Ingestion Overview +The underlying ingestion process: +1. Receives an explicit request or background cron trigger to resolve a `scholar_profile_id`. +2. Connects asynchronously using configured HTTPX adapters with strict browser headers. +3. Downloads the paginated HTML feed for a user across multiple page iterations. +4. Uses `regex` and DOM-invariants (e.g. `gsc_vcd_cib`) to pull individual publication blocks. + +## Handling 429 Too Many Requests +Google Scholar aggressively throws HTTP 429 responses if multiple concurrent tabs or rapidly sequential commands query the same IP address for specific API endpoints (like `citations?view_op=view_citation...`). + +Scholarr treats these distinct from random network timeouts. +- **Network Error Retries**: Handled via `ingestion_network_error_retries` with a base backoff of `ingestion_retry_backoff_seconds` (Default 1.0s). +- **Rate Limit 429 Retries**: When `ParseState.BLOCKED_OR_CAPTCHA` captures `blocked_http_429_rate_limited`, the system applies a dedicated cooldown. It respects `ingestion_rate_limit_retries` multiplied by `ingestion_rate_limit_backoff_seconds` (Default 30.0s). This prevents the pipeline from fatalizing a user's job completely, pausing operations seamlessly instead. + +## Publication Identifiers Loop +Once a publication is built, the `gather_identifiers_for_publication` module isolates keys explicitly. +- **Local Parsing**: Searches for direct identifiers within the HTML parameters (DOI patterns, arXiv regexes). +- **API Fetching**: Queries secondary bibliographic platforms sequentially: + - `export.arxiv.org/api/query` (Queries by Title and Author strings). + - `crossref.restful` APIs (Queries by Title and Author strings). + +These identifiers are accumulated in `publication_identifiers` instead of being bound as hard-coded properties, maximizing matching resilience in the automated Unpaywall PDF acquisition stage. diff --git a/docs/developer/overview.md b/docs/developer/overview.md index 4d9880e..7ab91cc 100644 --- a/docs/developer/overview.md +++ b/docs/developer/overview.md @@ -5,5 +5,7 @@ Use this section if you are contributing code or running full quality gates. - [Documentation Standards](./documentation-standards.md) - [Local Development](./local-development.md) - [Architecture Boundaries](./architecture.md) +- [Ingestion API & Backoff Strategies](./ingestion.md) +- [API Contracts](./api-contract.md) - [Contributing](./contributing.md) - [Frontend Theme Inventory](./frontend-theme-inventory.md) 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/features/admin_dbops/index.ts b/frontend/src/features/admin_dbops/index.ts index 2f042f5..0b16b3a 100644 --- a/frontend/src/features/admin_dbops/index.ts +++ b/frontend/src/features/admin_dbops/index.ts @@ -41,7 +41,6 @@ export interface AdminDbRepairJob { export interface AdminPdfQueueItem { publication_id: number; title: string; - doi: string | null; display_identifier: DisplayIdentifier | null; pdf_url: string | null; status: string; @@ -160,3 +159,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/index.ts b/frontend/src/features/publications/index.ts index f238a0e..3f80806 100644 --- a/frontend/src/features/publications/index.ts +++ b/frontend/src/features/publications/index.ts @@ -19,7 +19,6 @@ 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"; @@ -44,6 +43,7 @@ export interface PublicationsResult { total_count: number; page: number; page_size: number; + snapshot: string; has_next: boolean; has_prev: boolean; publications: PublicationItem[]; @@ -53,8 +53,12 @@ export interface PublicationsQuery { mode?: PublicationMode; favoriteOnly?: boolean; scholarProfileId?: number; + search?: string; + sortBy?: string; + sortDir?: "asc" | "desc"; page?: number; pageSize?: number; + snapshot?: string; } export interface PublicationSelection { @@ -74,12 +78,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/index.ts b/frontend/src/features/scholars/index.ts index db19e76..ef3c8c4 100644 --- a/frontend/src/features/scholars/index.ts +++ b/frontend/src/features/scholars/index.ts @@ -54,7 +54,6 @@ export interface PublicationExportItem { author_text: string | null; venue_text: string | null; pub_url: string | null; - doi: string | null; pdf_url: string | null; is_read: boolean; } @@ -86,7 +85,7 @@ interface ScholarsListData { scholars: ScholarProfile[]; } -interface ScholarSearchData extends ScholarSearchResult {} +interface ScholarSearchData extends ScholarSearchResult { } export async function listScholars(): Promise { const response = await apiRequest("/scholars", { method: "GET" }); diff --git a/frontend/src/features/settings/SettingsAdminPanel.vue b/frontend/src/features/settings/SettingsAdminPanel.vue index 7edf67b..e5edfb4 100644 --- a/frontend/src/features/settings/SettingsAdminPanel.vue +++ b/frontend/src/features/settings/SettingsAdminPanel.vue @@ -14,6 +14,7 @@ import AppRefreshButton from "@/components/ui/AppRefreshButton.vue"; import AppSelect from "@/components/ui/AppSelect.vue"; import AppTable from "@/components/ui/AppTable.vue"; import { + dropAllPublications, getAdminDbIntegrityReport, listAdminDbRepairJobs, listAdminPdfQueue, @@ -42,6 +43,7 @@ const SECTION_PDF = "pdf"; const SCOPE_SINGLE_USER = "single_user"; const SCOPE_ALL_USERS = "all_users"; const APPLY_ALL_USERS_CONFIRM_TEXT = "REPAIR ALL USERS"; +const DROP_PUBLICATIONS_CONFIRM_TEXT = "DROP ALL PUBLICATIONS"; type RepairScopeMode = typeof SCOPE_SINGLE_USER | typeof SCOPE_ALL_USERS; @@ -81,6 +83,13 @@ const repairDryRun = ref(true); const repairGcOrphans = ref(false); const repairConfirmationText = ref(""); +const droppingPublications = ref(false); +const dropConfirmationText = ref(""); +const dropResult = ref<{ deleted_count: number; message: string } | null>(null); +const dropConfirmationValid = computed( + () => dropConfirmationText.value.trim() === DROP_PUBLICATIONS_CONFIRM_TEXT, +); + const refreshingPdfQueue = ref(false); const requeueingPublicationId = ref(null); const requeueingAllPdfs = ref(false); @@ -433,6 +442,22 @@ async function onRequeueAllPdfs(): Promise { } } +async function onDropAllPublications(): Promise { + droppingPublications.value = true; + clearAlerts(); + dropResult.value = null; + try { + const result = await dropAllPublications(dropConfirmationText.value.trim()); + dropResult.value = result; + successMessage.value = result.message; + dropConfirmationText.value = ""; + } catch (error) { + assignError(error, "Unable to drop publications."); + } finally { + droppingPublications.value = false; + } +} + onMounted(async () => { loading.value = true; clearAlerts(); @@ -686,6 +711,45 @@ watch( + + +

      +

      Drop All Publications

      + +
      + +

      + This action is irreversible. It deletes every publication record across all users. + The next ingestion run will re-populate all data from scratch. +

      + +
      + +
      + + {{ droppingPublications ? "Dropping..." : "Drop all publications" }} + +
      +
      + +
      +
      + Deleted: {{ dropResult.deleted_count }} +
      +

      {{ dropResult.message }}

      +
      + diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts index e8ef325..6ff6c94 100644 --- a/frontend/src/features/settings/index.ts +++ b/frontend/src/features/settings/index.ts @@ -19,6 +19,9 @@ export interface UserSettings { nav_visible_pages: string[]; policy: UserSettingsPolicy; safety_state: ScrapeSafetyState; + openalex_api_key: string | null; + crossref_api_token: string | null; + crossref_api_mailto: string | null; } export interface UserSettingsUpdate { @@ -26,6 +29,9 @@ export interface UserSettingsUpdate { run_interval_minutes: number; request_delay_seconds: number; nav_visible_pages: string[]; + openalex_api_key: string | null; + crossref_api_token: string | null; + crossref_api_mailto: string | null; } export interface ChangePasswordPayload { diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue index e61df8d..90d8513 100644 --- a/frontend/src/pages/DashboardPage.vue +++ b/frontend/src/pages/DashboardPage.vue @@ -64,6 +64,7 @@ const startCheckLabel = computed(() => { const isStartCheckAnimating = computed( () => runStatus.isSubmitting || runStatus.isLikelyRunning, ); +const isCancelAnimating = ref(false); const displayedLatestRun = computed(() => { const snapshotLatest = snapshot.value?.latestRun ?? null; @@ -102,6 +103,25 @@ const recentRuns = computed(() => { mergedRuns.splice(existingIndex, 1, latest); return mergedRuns; }); + +const recentPublications = computed(() => { + const stream = runStatus.livePublications; + // DashboardSnapshot's recentPublications is compatible with PublicationItem, + // but let's cast it slightly to guarantee iteration. + const base = snapshot.value?.recentPublications ?? []; + const merged = [...stream, ...base]; + + const seenIds = new Set(); + const deduped: any[] = []; + for (const item of merged) { + if (!seenIds.has(item.publication_id)) { + seenIds.add(item.publication_id); + deduped.push(item); + } + } + return deduped; +}); + const activeRunId = computed(() => runStatus.latestRun?.status === "running" ? runStatus.latestRun.id : null); const showRunningHint = computed( () => runStatus.isLikelyRunning && displayedLatestRun.value?.status !== "running", @@ -178,7 +198,6 @@ async function onTriggerRun(): Promise { return; } - errorMessage.value = result.message; errorRequestId.value = result.requestId; } catch { errorMessage.value = "Unable to start an update check."; @@ -186,6 +205,28 @@ async function onTriggerRun(): Promise { } } +async function onCancelRun(): Promise { + if (!activeRunId.value) return; + errorMessage.value = null; + errorRequestId.value = null; + successMessage.value = null; + isCancelAnimating.value = true; + + try { + const result = await runStatus.cancelActiveCheck(); + if (result.kind === "success") { + successMessage.value = "Update check canceled successfully."; + await loadSnapshot(); + } else { + errorMessage.value = result.message; + } + } catch { + errorMessage.value = "Unable to cancel the update check."; + } finally { + isCancelAnimating.value = false; + } +} + onMounted(() => { void loadSnapshot(); void runStatus.syncLatest(); @@ -252,14 +293,14 @@ watch(
  • @@ -328,6 +369,22 @@ watch( />
    + + + + + + + Cancel check + + ("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); @@ -190,27 +189,42 @@ const selectedScholarName = computed(() => { }); 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 seenIds = new Set(); + const deduped: typeof base = []; + for (const item of merged) { + if (!seenIds.has(item.publication_id)) { + seenIds.add(item.publication_id); + deduped.push(item); + } + } + const normalized = searchQuery.value.trim().toLowerCase(); if (!normalized) { - return base; + return deduped; } - return base.filter((item) => { - const year = item.year === null ? "" : String(item.year); - return [item.title, item.scholar_label, item.venue_text || "", year] - .join(" ") - .toLowerCase() - .includes(normalized); - }); + // Client-side fallback: filter live-discovered publications that haven't been + // server-round-tripped yet. The main server query already filters by search. + return deduped; }); function publicationSortValue(item: PublicationItem, key: PublicationSortKey): number | string { if (key === "title") { return item.title; } - if (key === "favorite") { - return item.is_favorite ? 1 : 0; - } if (key === "scholar") { return item.scholar_label; } @@ -220,38 +234,14 @@ function publicationSortValue(item: PublicationItem, key: PublicationSortKey): n if (key === "citations") { return item.citation_count; } - if (key === "status") { - if (item.is_read) { - return 2; - } - if (item.is_new_in_latest_run) { - return 0; - } - return 1; - } const timestamp = Date.parse(item.first_seen_at); return Number.isNaN(timestamp) ? 0 : timestamp; } const sortedPublications = computed(() => { - const sorted = [...filteredPublications.value]; - sorted.sort((a, b) => { - const left = publicationSortValue(a, sortKey.value); - const right = publicationSortValue(b, sortKey.value); - - let comparison: number; - if (typeof left === "string" && typeof right === "string") { - comparison = textCollator.compare(left, right); - } else { - comparison = Number(left) - Number(right); - } - - if (comparison === 0) { - comparison = textCollator.compare(a.title, b.title); - } - return sortDirection.value === "asc" ? comparison : comparison * -1; - }); - return sorted; + // Server already returns data in the correct sort order. + // We just pass through the filtered/merged list without client-side re-sorting. + return filteredPublications.value; }); const visibleUnreadKeys = computed(() => { @@ -437,13 +427,16 @@ watch(hasSelection, (nextHasSelection) => { bulkAction.value = nextHasSelection ? "mark_selected_read" : "mark_all_unread_read"; }); -function toggleSort(nextKey: PublicationSortKey): void { +async function toggleSort(nextKey: PublicationSortKey): Promise { if (sortKey.value === nextKey) { sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc"; - return; + } else { + sortKey.value = nextKey; + sortDirection.value = nextKey === "first_seen" ? "desc" : "asc"; } - sortKey.value = nextKey; - sortDirection.value = nextKey === "first_seen" ? "desc" : "asc"; + currentPage.value = 1; + publicationSnapshot.value = null; + await loadPublications(); } function sortMarker(key: PublicationSortKey): string { @@ -476,9 +469,14 @@ async function loadPublications(): Promise { 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); selectedPublicationKeys.value = new Set(); @@ -498,12 +496,14 @@ async function loadPublications(): Promise { async function onModeChanged(): Promise { currentPage.value = 1; + publicationSnapshot.value = null; await syncFiltersToRoute(); await loadPublications(); } async function onScholarFilterChanged(): Promise { currentPage.value = 1; + publicationSnapshot.value = null; await syncFiltersToRoute(); await loadPublications(); } @@ -511,12 +511,14 @@ async function onScholarFilterChanged(): Promise { async function onFavoriteOnlyChanged(): Promise { favoriteOnly.value = !favoriteOnly.value; currentPage.value = 1; + publicationSnapshot.value = null; await syncFiltersToRoute(); await loadPublications(); } async function onPageSizeChanged(): Promise { currentPage.value = 1; + publicationSnapshot.value = null; await syncFiltersToRoute(); await loadPublications(); } @@ -605,15 +607,15 @@ function canRetryPublicationPdf(item: PublicationItem): boolean { function pdfPendingLabel(item: PublicationItem): string { if (item.pdf_status === "queued") { - return "queued"; + return "Queued"; } if (item.pdf_status === "running") { - return "resolving"; + return "Resolving..."; } if (item.pdf_status === "failed") { - return "failed"; + return "Missing"; } - return "untracked"; + return "Untracked"; } function replacePublication(updated: PublicationItem): void { @@ -784,6 +786,18 @@ function resetSearchQuery(): void { searchQuery.value = ""; } +let searchDebounceTimer: ReturnType | null = null; +watch(searchQuery, () => { + if (searchDebounceTimer !== null) { + clearTimeout(searchDebounceTimer); + } + searchDebounceTimer = setTimeout(() => { + currentPage.value = 1; + publicationSnapshot.value = null; + void loadPublications(); + }, 300); +}); + onMounted(() => { syncFiltersFromRoute(); void Promise.all([loadScholarFilters(), loadPublications(), runStatus.syncLatest()]); @@ -792,10 +806,18 @@ onMounted(() => { 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(); }, ); @@ -843,7 +865,7 @@ watch(

{{-u!95pKmNs-Kxg?coxa8&YQiFfmTi)?}zY}F` z;(zeh`%;Qk_lNh)GP;8l$>XY#taszg9!2q_$Gh3weP*=XJBNE=eHfF4V(zmx*8fZs z8CH?CHg>B?ZIS)Hi(k_}yT~Bb!hOq_3PPCqH^mopCoJf6I6C_}S)$*vZ+sTLpg8l~0>`^}m72+t$$DRXGj$l_WWD+m$A`E&TUkc6JKS_ax)@ zXUm^cFHQK#HdB^#j6#G=rBaF$52VG?MEgAMR$59*iEQ}Zcg;E^VPWYYg&e1z{u#GM zyk6&w;6^WxvB4Myl{}qb@5pOBrgF##C#kyb6t{?_ij|9Bn?=QM?MsJ4_lj+mG!D%< z7+|n)t;fY;x~^NFjB{N3{VZ23=^gXXq--~)(|Isw?4efwd3EbH|2~!~-{L^(bn^#s zkq(xRiStJ;ajG=+Y5v@Uud!Fky7~3n)!8EFp2nVe|HZxc znoZ|u?UYfyXNFkahz?z)R@_Hg@fzQ|#joCH@GwKe61FZex^DI>zVb@blpeK?o@=U4 z%~$M~%&7cjmQoGc^RUk@x#?B8@5;$cAB4jLb)khuW?N(Fw{D4j;+NZ=;$82Ctbs9w zsUC*TVY5>A+{%`1yc6eiXGrzhQ1OypABs!do1N0B2GjNu`(<^VKF%CUPFwp>@lL^m zsAs-Z>Z+$rdeN*J6O0-C z`hm;vxXi|(3c0@Xnma~!7xDPTHG3#t$>fpKX&B#{(Bw4H)ltLS8^7cs)`oh`kAZ=b;|9I3N@5>H%&G&m9-(9|n zqT+AySno>mgU_3$_iwR|6=1Rwc1GmgI|wxiwR zBF$61aBkFVkZt_l)w7pr)hbG5Wd`wP-6m#wUyfQvr&45gk5)-Wzf5`a@(N=z@3y=} z6eb+bx--Pzk@}(mR*~gGbo8S?J!YbLoD2X51*Gb6l!Uqjcqemqkq? z!v6H8%M8**{9-STD8AmfOoh&nJupJeXFD}Tp8HCiO}aLdFL&ScxcqV_k?d_IMHV)`i~?!a4fvV@QFljna;C?gOK} zUJ;iMTQr4HqJBgip`%bFbaW_1DML?U$J*R={}^oI_;Box>`RbOc5+diNqmAf3kPKz zdgZD?LfRB;o0-a9)irjLZ{OdHiQf^IS1?r>&P7D!Jtb|u@5OO?Qm&t>N?7ucHUy} zgrLOC8sz<>YuSyssBPgRYFSa24rC5AJv8QOXDe>f)>&Jq&>o?2_^E@sv8YAK;zh8w zbdHFWM;NWi&2M=u84jb*5m;Vw)x&Juak z_&}U@s$)KM%i~W!pHr^iwLwDVFBx9EP2)Jf7}W^4@0rsi2jHD)1L=T4&%~L*dF5%NQ_`r>?-j zEONPcY7TMqz0UF2uX#Q?z=G}Q!)pWGd8rf@%Fb2$TJ$w~_GO-@S9y9nQ7ysuYM%x3 z=va%p|ES5B`zS|7ZT1@3E9JVG6DAMrb}xR!2uh#ihK4l0zF57n>X;4lV#UeTd;nPt z+x7oZizOqyi?XAiMY>hIuG!ysPV9SmM|f@$IjcetIs;NK6^)XMsfrD$3>jZd(8V02o{i$bZ4TE`N3 z%(a;NkEiGe`|9l?fgk)sC7)2Q-`Dv_W#4VYnxqp-r^tAgb1pkFY6MR>SQlh`mT&vU zV6MNg7MHI?-IG+^wx{ICVciUex<0MqtCYF&n`c$l^BEtj2$QT=-Z?;rMFUf@EQaOk z{^O=`FXkD(Z}~ks9Q~nP{6wa?qlLs>?kzfbVJV$2T-%t-1pkT9PXAbpVHR}^ff1>`N+p>o_u*Kamb9}$1z2vJggzkiNS^LW&YwpQ5qNya)P#f~&wu=%&V$R;%*lYj81?D~5hFBaA5_D}`N1-*>( zNyov_6nAbB_JBwJMpJ*8YfNvu&CjvXM3?DR2rbu2W!oOa{|ry@WUJI4s?zs(J0P}A zH`<+E)b(zEVio%>+MDw>AH<}*n4C04KBU?|KM>Bp@aZpTgoOm87-WguyKyl6=F1m0 z>#J!YX9bH#+;d&)6zC4q{h+UNR&U$CIj*s(ZR3?N?T|{j?_=mJII`v5d+9V`Hk!>p zFO!e%dGt@$^3*g>rL?oBJ$>hWu&-VuT;!&sNbCJ25i1vC-x&-%%Dr>p!PPZp61Vr3 z`5Ij+G0A!qbY$+egxz_eJ%{^m`FOrm2)g%r<-qB0hvG9>f2JJ_*JJ#tSG<+>cfr8~ zm~6lKdi^blFbxjcRkJ*>_YXQ;I`9_6hm20q;%!WqQarW4d9(%h+ZX;jKK?{sAdUG# zRPox%T|){!&D-Rtv1hN>pSoYJ#xA9Fk1`)9F}{BPMP2c8=35n?U%QXlO}LlpeT#C} z%@T92rgNWacE?Z+w}m+!o0VR*Y43yips`Vw;m}tV#?IQA%g+eG9qNToQKv{TSVf_5 zZ)b`Kj5XohepIbMzW9f6olnqz`RC?EJ=<8;;AG(uqZA5s*@394H5X~(b@=$l1SvYY zl)BuXG1X>6my#bwlu6fiN{ASDjj?6i=9QkwK6+BJReP6r7en+) z%Y*w|5f=rAetCN)aYylamgr{-)I8EZU?J9;xQ+$1u7~@Ovd&KCO8G$jL*?68VxAdr zm#zzq+Fo~^E^3Ht;rlYSPzU+aHH!q@wF9XiTkhuSW$ve+rN2e@9#HoE+N{APIzOIc zFq2oFTD~WHnx9sX>Bv7-X#-i6r^|=5l9xKj#dA-KPYCEZ@9h*i^J~Vxc1mk7Y{omw zRbSh2%OXG&y~<0`W%X{lqt|z!T$KB%&i#YQ`z&;=4;MFW*psMFSzVA`7DcgODS4wh zx0Zd)DUs}oH~!v^c?Sl!GHJvQZ4+7y3XI>&?KiM^t@g~llG*E};}A({^YzCQMBJYW zIMN)&dVi_WCJe)0#BKi4lTFV(kG=9GH zsZQ`#X{}A>PCmB9eJ{2iYBsVcT{#|_M=_R|e2Hfu=gS1lYNz)ZUXhkyRFMWx()Z&< zm-)9owFgE&wRh3@|Lx+YS|q0lQ9q8*gr#OhALd#_`^-|0J&KVv5m-F+jH;{2Tr(GW zv%;R=qeA+De9ySj<=83356*b&94`%JbPQAWt4@bkddP;Gh);|)cdc)NmYzq`HQsIuM+XP^+zQr z^80iZkc=1xzsM?v1_=IRIM668DlE!d_&tta!ota`#62{im^JO4oikIn(9VFZ{H$rc zv5WagBZRt{D<OY2o!+S&c1`JuV&?|S(Oryu6P~!LBnjWTEyK1O1wsQ=5s%B_ zG?1g6Ai)~Va&8f3+3c456P=Pbj)xi+aJ_yVSNt(S zFwL@nq;bkhxTG~sd95fxD~0^c_LokXVVZUv7IFJIy$TXU!lE;jxYH;WvD?pXvTTiB zkG1sIT|aM2Q2$|+1o0WxiaDXo#Jt(6#FFG3gAXhl2*YgJ%0Aoy5sk*%-{v1_rhF#P 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%0DXuA7S0vzlJsX;Lnn zMWK5Smx^1MeHo5%*4x#~-k)RjFJH^QLOQn7ECRD4jrq{F$>E@f>`=Q>@%d1s^q82!I4A`x>Pz-_jX+`ihU;Lt5)jq5n4FTYrBLOk^C795K-N#CK+kU*P*N30?@2r0YOs=JKVa+4hj^QPc={9)wmWW z$^1eHWmdo9vpha()*hB)pTZIHuL&Z6uc3n;faNIa@QE}q15le=AbF#=F2L2`mJGfF z$iqRN4$oYT?ARJj8ixLWX)2r;YuWtCPVqB4cB&vv`#!767t4(^#Y$dOCRxz)&QH)1 zd+x&FT5+#mDn@V_>GuO-M;L$D%a=1Nb=t{yR%pXd+g6VlKWk1OB~y*d`tVu;0Y`L* zpU{&D@el`aYl_xO&>IZ#boEEK&lyOcu*)GYiz_Qb!G6m4^P}ukSLnT7g~;+`M+7@F zHqN;0enZyq+D3Y8Gm3rcKKj$mG1wVgV8q% zDWku|g*WEnzfuVn?PtZIw1pP%O7jr3$ez4_eLG3uDO~WV>@;&g-OKz`@>b%275Uvl z=Js)#3bhz^!4)cVe2@SgIQQqL$vr_l(7g$Z$VBP-O?}5ib$XA=Cy;v>;8K3D)q&X?Z z7aw}Y%QpYvtHi`_O%kiSvq=S2m&rW9tmM(%8{v;}lXo>p!^D{rJWc_7g4d&@m3GHI ztiflt7H);~7;gNuKL#gndAg-dM-*J1R>BW!1sGmu^{wI`e=0238fp))%A5lVDI{cM zW3_h*4PJhd6zITK5QY!P9la>ZL1OASG}#D{2d0*H0CqcyRH>Yx`aWeupSmR!-J+1; zoDCLlHXxGHZlU;ZNcB>`yA{IvdW)qI5PYjP#p90 z?lon6F!kQ-582`{iXJrcHHk;(yUSfd1TF;U-(^t=G25Ox>lxNqaI{bW>?^Qlw*dSV zY8VUfY!-5WB(nlKhE9PK1)2a07vA9nCQiDB+=+HgA;KDtg>Vx`@m$W%=MQkhATG`) zyoo#aCbp`z{{v3tjQF2xk_X1B8Ct$AzMTnyJorgLa~;?Dzf^D`Ms9tr7yXuzo|eE& zHf0gT6#9&a$DXBjE@gC`mt0lxvZiPFL1F>u4HPfYdTuzo!#kx(zT}z(*}ajeCMq$>Yb5fs!!5 zYZlh~D)#~&exJl!>$OHV2GW*mqk^fI>&>R7UAn}ic;sIe$h#KS21!#RRkvOk<<-W~ zKOIvrZHdZyaYqxE+S5!~3JQUz6D95=1IQXyi zD%iuJ`NCiLC}fh&fEUn>#Z?|HQcV_qtqR=(Xtvrok!8e8MfQkmnY5%>6f|jfVQR0aw z7%1{yps#XUHf>r|yLod#@(f&|d@d<2FTc@1n(`Wgv&qmoX6C^RMwFUrGL-UIMoftL zg~W-{gSS188Fqf+{&Vjoaj(4^X!_vn`|k-@-5+)`;FF+64$jV0Xv*+leMZe>kArp( z=Z&+l&0(jNpw0;m0^!`Mgcl9;+_78Viq%Wyjt$FUD_(FVi){xlZa}HS56uXSE$u4E zcr@}_govZ|=xuuq*)P1&g$Hd7*EQ$~>ID5z_n7)ZzTQDjBN1z^g95A;qcu4OA zba1{!!1&Gel76L|yFG!I61fyYVlC||V!L88rc1vlTi9u~R*yaF! z6vPDyOi2&HjtGd75C;Hhlg8(baqYzUdE#Sw33u&oqqh(-b`9UZl7iCd&z@dDUVY{lXs;oHQKKjVQd(t zMsX2Hp)RNo06|19zGk&WTO0XkRRcFWC|?{H(YiQ2#KKRs*k&1_4)m9IPP1YmvQ^(zL$fws| zc&eF0Q>MY7nl@rAci1z$_%`+n#Mnll-SczegQqx9$0&|Fir2--AdaD@z-!lf;4}-x zVU)89+8v-L1HoU~#wL^6*%lDjR}xu?a8VT7e0j^{*9=Cc>XH(;xBFZoTXF&d7yQ00 z=Pb4E-|Tn$%R}>fWjrODAvBs0keEm^sv%nz71fJ^~OJZwGS zSb$bA;iBaCriF!t4CM|Wt}`gT;A$DJN$wPjcFV$*KDUR4uyM|Y-8%Sp$Gbio@LM7d z2%fupLD80gWNhcq!c)#qx1t79Ww^}+ zV~~X5=o|`PI#dD zjLit-{IEm>zz7k{^59GlR~($$AA#m26gO?hx7piTl$Ya~Iv8-r(o**SdJkzd%>cvu z1u_#3(B{IOl@*3co&xc>r9db@fljmp&4$WQi7hxcs9LT@*Dk(|i z%W@Y;yHHmdz-J|CBRVR#8*};!U8kMlF}#VY??oO-WRc_)Yfv}6!Gsu^o~-wd>tC>_ z+rmSl+cxw!k~Nx%MDV_z{n#r8w||4ol=-m)3;w19K|sGZ8DcfU`^-Valt3nQ;)NbP zVMXxm3!_i-mAMPnFTXwoBtM82F52&O+hL;xpfS*_yL4A`27b<{*yOA{7aZm_Je#HH zNzP>MlXVu8MNTd+06i5O*_Yq{lmuC?&WRvw-kZdirMkpM!y3wRigS3}`I^s@|LM-Z zqrk6kp7||XMNHgN;@R)lE^)Tv|7Tci8J=mG|L##JIf^$jGI{(!7!w&ixi6T zT@pOuK(GPBKS&#rXGkQj(7v*-Z##Q|8+oyjU`82Q7o5)h3rCHgD5J^t>--^i7T zHL1st2em&(R7O(HD`|U4Wpgz|mCy@z68RAtDOHp`E#FwXAlTk-q??*;>D@o{RBMd* zP_w6GI%P&6HJBDh^Bf4`M@?9WWTm(9>t)dhDLvV9HD=Pp$d>V*(5ubsl+VpwTy)D9 z&UvEhGaz>b%A{0%!Wy+%RM|X(6F|oymNnofeNwee%E8)~fXxWoBQn1t8{Xg7W9e8o z(tm11-m$Q?h4_>cIikzIod*=j|MOm}XJm!*)JoCUOO|Ydm%`Cmb)pkC8L;zPTNC`X z>Rw$L-2w%9m|iF#YB?gF!LOFj*(@cd%IlOafuFdmlrukm$}6jyljGL;i*1v43~&k9 zZnXot%XjCK_tvjjN?-3+XLOeYQl}`A-@7>AtJw=|aNX!TWhqULj8Mr*dUggYyv%2-%)_!E zQ$~R=8nYtE)Y>v#VlsfAKlms6OQ%1Ja;jc?s>0wXoE8>(Zwj9g@-$ScGA-a+{7;NK z5hnXv<7D`xmW&_0^okAieHrjP1-LHcOaql-A+X7eA06&P@bnl=ZhktG(9zV?j?7F6 z;L-K03GK7{g&fqz6dlFO7GCMADM)WXG`-#L`4rwukMou03=T{fC_T3(0-<;wo^M>W z;Y7Q+e|WqdK2j$aKR)OPk#?#Jb1QNhJ~=tfd+&>}&Odv;fXN33JbYY#PdBAV$QV%_ zG8DC7f`4vx`Nx?Z4)5_D(ugdxjap0?iSrya40QV6Oi_T$&_%riP^ zZ09JNj?a?d4*}C;C|g0*lIP5~494nTSDyd>yVR3r{~o(QYJ6!;;+{DLHr|grSRhU| zUd*JHr$F6(hlgb^Mt`0D{828zh>|2Ebw^%2A~dv=6X|j21wSC=u$u`0n~d7cRGr<-ArN&#h(|3SJv=H#M>ty{c?EN5x0@b%OIPi(mzvuy%1__ExkFo_ zFAvRWcAOWyNu`?9IV&FMcTEjC;c=C6>f=U4+*MV?Nj_a|Ob5t)zQvWf(qxe1LuQgH z{GVlQ%g9BOb+V1HUkmA@pTT8*E(h)b;9T12>6Xm`TPJ8CuRvCVOwm8iZIb;l^^g4( z-?CO$k2v2|)>tO956IQQslYmaFa)21wf1gcV`&;zDltK^vQdB zp1KWABrDfXdbdR#-c@Vw_Bh$fQD32yB1~*x6tX99 z6+Ts*?I*X`bYjbie~@2_Rj7>HR48~+3rXhZlE$?7{jk<49Zg z8%+sf5NvVls<7IFQhF8YCTGV3FMWd(>RPyO?ici40Zq|OiQb!>WQJ)bEuPS8pU!CAqs67rmdkqc-y z0$3f;!{Itamp-Qs%Tp%(e62jWBs_t^Y%w6psEFoB)bGVme8#7etfn7AVxSoGtf1)6 zHNDGg`5}QA%zL+MwyUA$`74zFe6(hDhRVgaHzzaVO>;iI-LAzR0&5RFN7MXg`!fob z5jZ*+(;C8*sAmg?e;p_VnIL3n;12Lq{fNWH!{Z!`HK-8!_42G%q>lPuJT>;Zpi7vQ z6L_&*~3g$%5GP1Q$^vmL~BoVF=e)!51qkzIwvp$7E zHk5m7a&PZ5-zdBK^}`a309N!W9 z)+2JnJ;+fqBeD3qWClb0RVbCQy$G*jR+x|fdMq(|NDrIG&Q^J?$wM*J>xWM`4D>s` zCBLpb8wmQ}VdY74ifE6vdx63oAJc(L3!h_Su@L$i4-UkkZu5I2qX_QWP@c9-ibiRS zsBe2M1t>wS=7RNn+IsoL$KTxWqs(ak&~n56fdSWB)J-A_E?xmFOe;T~yXSkm zI3zVIw7bzOpx-@IWO!-`{x>Dy1oS#sR zJ=8C+B=+A*amK6TJ;KwZE+)ecxH@6_-U#7b;_TN3f+}GtiP}#U_nnd27=2P(E07vM zL5|xI`n#Y1u-60F#I=o_isZtt@P;`g_x}`o5qB4v!I{!}8z}qg;H6EojXK9#Tzfn4 zFtGhbxWM5$=(0SM6q_8@r>8aqxU*)xj4y!Fbe_9?ThHrX#k8OJ=?d<(7`0c@nHRv<1 z4=%FL+s|EMLQ>tk@(ypf%o9tKQ-US#&i59{_bRv0Z}%Khp3Y&yC(__n&HQ!xJ9=pz z3N?9?q4P%c|BW{mXwG-@nY2cDd$N8_P~y z#4}mvk7rQr>C*fN`7>Hg?|Zas^TXAT&zH1TN(r%5mi&z8ciW4{h=r|Pk-fnXk(2D` z?xrm(d)S#EY&%@WYw9sg(^z`@@%B>FY?ALwFxaH|_Fj$3sf$U%GudIdOs4%juDiMh zk13fw9?h_SkuKaJKYS?Qx;z>tA9^O^aL>0tMK<`7O&5dx$YoX1I zA4GrW759`m+?tx1u^9R6^6JvBD=VxS!6hWc_5tDT)`l(D^*5lXT@;n_p;4;*1BiBLBDOmvhl9g2d(v zs9>Xp!?JMS9ZW$F9Iwcpm!ywI_Qpu7IKKSaj5r#^8}?h2`jMTUF2{$#M2tONmZ;E7 zQ>ZcPKF>_Ur4tc+>fCwSGiN?O7a5J}r23`uF=B=6M)d4S71Fb<7Z6R^in=5_p#Ww!kRZnyJtnZ{TYF35{N=n3A ze||j4P>Hty|6(6~rL3qZN*==^d@;ABz7vzKt(TOJG8b7Djh;EZm?+eW`BB@6Ux8;X z>c@QWLGE+whWYnAJijXaQafEQz{UFLHx^Aa2B&#$F8Js_5j?w0-1}>@?utesBPOx6 z=*6#J7WTtsr*riS&eRAs-EV4YdUf4bvdsQRKvY!Jr%#PCQO(Q*ho{AMrJk zzSsD7uve(RlOjq{>Ak(Z^`xG+1QYt1Fu@YKa!;>4n)7oT^=MhPW8WOObw5Qr^CmpC z2>H)bZ^2Wa5RzFO?rmZ)n3c+F?+mKkAHozLA|(}WR;Il7+VPO%u0eKidYXrlNs;;1 z{j+b`J~ARmI;Zw~)d*{k;xQUxk|)BfaW6S2Q`D0r=e}izkM&=2*&cD59DTgJSxFZo z@2(#4?;?}>A*F`wz*0aT=%e346%-U?P-;iBwKPI6@~gmc+g5P>_GQ0^Qa>JHiyV$p z3sgQ{HDW$JzDZ`=9v60$KhG1fahWXOCk~65=X}Nu#B1D5KHy8&klW2jjz}jbr%UE_ z$fM=ak>#8(^P|;bqwbTZfkR!JD`Ob=pZ7Z7H;)x_{Zj{fkEVgaoLj6os}iMNzEKdP znCln(y}i0I^SVtoq=`6W;37kbOj7zK!|GB-f`=`HWq4AH^&kXzg2S*{DJg9(vNLGQ?jaOlS(F`sxx+3!|HH zd-qtn5|wI_BqN#R4hsyomXFVQiBYQbrr-V!$rfT7?={P@!_|*P;`q(p6%r%5P!no_ zK`y*E$QKh6W6)NxRrYM%&Xw$=_}g1ylcl1Bk17a}^p;ZyVt|-`9*bp3O=DMTn2tAl zIp&LHgv&a0<*sw!xdNHG#b}ks9GG`0cPUxX{AU73D@FyvilnBdHt)~T_0i|Jp!dP} zIT#W(zu3xsSi!C|^yT-rlrQ=IX9jW1`&7uu@9&%7oDb12cQiT0aFrZf8srL0tHnic zI6CsdWm+%<%ZcpFcQU1R{o-y$ zLd0weFY4ZiIvdBjfCOf;ox9*m5UuGm%Ww%jjnjv0=Q(GeQLy!--5|p$T~#kD#an*< z#a-fR{0H=t)Vs3qE^duT-<&eh(h6|-J#c%jC*`b=EjdKxS(wYv*13wx^=-P}Wx==k z;Y7DNd!a*+UCHQzl9n?f&7c^5`#TPclGa(je5PRP3fj8L?5mLpQ>@fQHOexzWa)3W zL#)b|hCKImZQ51A82k%(F&4V7TJV_FFo1J4 z*HrnFC5|I>x;Z+l*)wvZ;}X(FRj_ezmEmOvj8Z(9>tW?FDj~uIuK*85S2=B@B@Qh8n7E<@s?ON>Ni+1_ovpi!VR9{sB*l7n9t9LICO?Q3|2pqASm>ud)*M34gwY%XENOXNojdi%Y|n z%ueagu2lA9LIXp+-t(_oK?=^1ug^Q1}Wd zur2OurKhJ)jZpKw4HpfMzUlXh6meK7m^UFZ8vMqEP|4HgFz;^vvi#j~#`^>86m>5@ z+_O1{zZ~`V=YuUd5y{Dug?GPQWRUdwzPynB6O@C6)*WYJ;^OR*-Wk+*?dmKAW9Eww zIXaFCI%$xz{~(G~1$!p`$rF8^$3J9E88*wZ`(rO*_3fF^n4v3zfPg^YGQzIpDeU)r zebZtgJ_}Du;n7JM3Y%DVPM!x>>bnsiYMnDkOqi-zc4|UiV|P+DyAm9L{25FJH=`)f zUz!P=e0-f-L>iO#Wvx4ec>9vw7TU6k8*L|ost>|pBuJ*g-`xW z;mCr`=xgL%-}>na=<6V=DV-&?SRwPfO!W-?)oTn{R!B%l(NbkKoh2BLlfei5UVcAm zFKWeI$IX;D;j9r=qKe~*0meWks#cNkO5>J^&c5V}8o*4BxG3L`Psd$lr zbl-&F_#M57Za*s(XAQ|!s$GAMKco9b08dA2Tbn;m$5p2(A~fHKkx-4=v_nbR}!9V6VJL4O7#>v9DmX;%A0~#N>Lga$}~FJS_B>* z9`77knS?||MKPS5=-h-a0=wt_#Ygvk%ZE01Tc;fj&88J7-MohL^Po@8+kBC*`1f^W z;L``Gx*I*ww+Zo&htySbGG3=iprWE8%eX?2OD8MBMeOnP^fbtxlA#0kG2}c4V1DXs z&(%dAw#oLax#R2eb-PpdZB}Ytq>&<;yEs;igz__SLIS*Gp8yH5hD`|-l{^85) z4)maJMC`XKDfQC#Rq>AJ6vG&%7HgMSIXPL?V5zB0WOMqPM8&maEg4TEBo z&J`}EK*LVcY}6z};$kL)2?S!saEd zsYyY~a1}u?yu7@jn!VO>4aQxo&b9nOYG4h)c>!#ubBT>LK3pqk(eLddnSW@Kl<*hzSv8Mb-(fU zM;=pdm#ZF@kuUP5F*;djj1yVGiG*Dpu)yWdide|Ii)0ngwxi@JA%oq2)Uo!cDA17&8ZCkK@o z|2!}_*}E^_$z@E7qU z#)16)qc=lJ5s8WPR2#uEtW(p|v4>(JBIWiawcx^Jr-v=N0cvtnQ&ZIsPv)W)*$_Is z@0ibwZ+p6P^y3lRIQ%cdQCmAPd^js1?_7eXyv9XmY<;mA>nNIpMG`$Itz)UMD83f&@ zc0yTn@}H}QjUT+{|dHt_sTp6#0_MNVak**;bxWxUpx?6terflMXD+PDdsB_=oA z6eL-!`12L;8np8a+1dNTgR&>~X0{B;`tnc=nM?sE~3PWXMmr8sQU zx-j%y({3O-rSk>4W7Q&FZ<2Xh(spmg;jkx~{2eSg|1|+Eh66n7hxg^yBdGAPQPN(< zSKJE>`N-Fw2GaDrYYpcm`_sGeKnVHBkidwe1Lpy|2VId~e^;kDRm936QOs9tsSMXt zl;H~57oK4K@Y8o+LjE4zg=pLu_{O?JXz~7f9>FE?AFHkkGon#{2bP*)uiiv>;&JLq zzl^OIgCsuo%GzS~TO0vxY}&HO=_CJBKL}X-sc`ZFAur!Le_QI`_wlZeaj-9N*^W>O zLmd*j-gowr(#>);@x!c{j^kX;Y!>l z^3*Qr!ROedt?J{qD`yoJFP5GC@NNI}kX^7euIz;DO4757V{a$^c&XX*yk^vP%^P73 zEZb)7uf#daF7UBPDCLY_Kf-m+#NBlOJKTBn@26VaNZUv~m%l6GdDDc>Tu6*%0r6`_ z$~?8cvTvN*&#xg&I6&G)muEP}2OoTzURU5CvBz6+-59gJZp-0c_4Xz1-=($Y^P^Pr z$DMBQD3o*YpqC`TN*$E=h8BHBYp{eM(_ zcRZE<`~Q6$BYT!nA|sS2D|;m&SxNRNdy|!UY|09eBCDv($Ov&nh%&R4Bgx(~^LL$k ze}0e0_x$sIyu&&7x$o^#Q znQ5&g5yRhJSm7{A3-7MG;@R! z7u}v77TSL!d#E#-mc^eKavE zqJdtq{L&t?W&DawGK6rRiHmY^-HYY=`j0<|^3X0#$Scl+1;T%iP3?VGR&yv{Hgn_c zyV?fZ{jI7sb~P^2I#i41XvE=u1_L!Y|eZC4k1j2hv>^xUmrJ(0I5%vV*| zC^|JjQhG}M>L6P;arx$+F~Ps=<&jKs9ibwyr83WbML^$kV>{L3Qt^aH;O6}T)z!r) zhpPrD*K?`6BJ(|S4=C20sUj?Y7OzBiv&D$VPxQSxkP?*DfU%gK5 zYgObF;fir+PtT2i(G#U(O^(S!w(@^kj)?09w3`#l8=npOzIUo<^Kw$z{oiH$qoz7p z%ZnH14^%`%6{U7v?#N;4#pb8ZVN$vHoKK+c5zzdNg8OrJFL-wb`5vmi9J+AyZIGh; z=o%>{9lD${i{;g`!szba?U-iK6z%Rz%L64QECEmxR)hnz2bPb&UfMp>|*r`$d7X;Ar;y*nq8mhzBWmF)i8^^D-^d*Z4KHVpexMM38x zpLGxfE{YwCK37cntDl>&hgwiIQ5Omc1NX2Qze2HUKQcwRHYsGe7?1Qe!YNP+~YHN?K5^d%1W@= z>umLbbUeZvE#Ve|O;?cmd&eGKBFWt_=|^`|{;^^$9BA#g6tZH?YVlxORafuW(KirmyVZ};>ong8BS>TNM}yzber zjnIbvU3*B1?cB?joVZZ8@}XN7c$Q4RX|HwaO<^>72q(UN&OlBlIl+)uAV<$$XA46&aC?jS5=027jHxb(_RX9 zeX57iu++{)s$0;3<4kUhcf?>?(&O5)$A^%))?8NXL3LdtWcw!aY zZKP?x_;1HXt1VV8MQ)TbH32PPgCYN@8~f>iOvifzHM^(H?wFONAk8-%RuZ3?KI5_{ z&_`uG#koiS{U&V$=Shh~g%PqV*;>N9q=;(k8U^w_<-XqUBAbt6TZ_o^)mplLH*c7K zxUIo@_o&=_#iboeD%nSw0-qHqavyo8s_v-I=L8tu+qH~7u`x+~hfA@>IRLIF}gAbK1sM_lxuEMcw$s_gMuSx69 zbky(nPY8pbIxRVU>f)P4qMq}gBzQ|>8O(WJT~lr2l6U|P#L{IYC}C_HIO}Vj@10! z$3*^)mvQ8@*st}`s_n_H8zj0Xu|mkLs~MsHqU4Um6B(^zu+3hO_s`0R38z8=@)bX# zT+7!s&W&-H(f6ZBW%?#1{?kk5?`|JbO0T(je}Y%!L#53}=rYIF7hi5%{Pn z4gY%t$;MoXK4tIuJ9q^I0wq~W?4FCUy&P*(o9xjm`-_5K}M^^MJ_ zLvh+1tCYEp*jePF(PUU^_9bAZqqn6;WAM*>!RzOvSD9Og96Ce{*E1KtQ6V>K z(}{76ysD=Yb?AxcXlc210FdIm7MBIsHvrkU9Z7Un4cXgecZc}2e{R~IGBVWBakn-# z{o()6;h-Pa;MgoDnJaDG-Vpl|eTG-~XGh*%llffz_jJz?Nm`1jD1nQ8Nt7|V;5`K zaAG{LDBg>J>)6O<2bFrLpC-vvzv*M!-0*itzneDPV)#2wT0R8GcM5bd5_Hk}0p!{( z!*B0KO51j`zjyv#W1s(Yu@%tT{X?eWQc8y50V)9Y5*v;V{oVr);0{pZsUT~_Jut8y zyt#rMaG`3o4SbG~&1JLoxQYaQF%xnn-$yFV?qj-!hBX$@yWXPB=y#s7@Ze9z$}j|| zd~WZnFAj*`J&OL3FQGYv6Ya=s`r5QCFPXu1yX^#QBW1%8lqY`y)H%GFTIX@7*d(T9 zX(R=7rO{~-+k~-Fiz|bfgEMZcpBOuU0eXC0O>O`173o`6I(P;lgNk>CJuc9Y_TGr2 z%;^a68CH}gG3ll&a?U;G$4Ltode>^F#P4&x(LH8Nm$0CH zJ7r2y0u{G=Q!HAEV@+6w%B97>*m6c6TMIC;fFRtd`R){+UO2nn@ z>eZ`(mjQfpBoQ9=F%LT%_1&+oQgzkG#vo4x*87-5WlCPF+8nV-IKV?|?JQ*kDT)uT* zg2m2=yQ9pJTcgi%^745z<-`knh0o95DqJ97FBm<^<)B|XdQ#t_l+vu5g4mT6(;0X> zjJF@K%ZqlsTogN z`??E?IRQ7@eT5yh2IZqpqzfeZ2w_YSD&R75bcX?Cmp$CMo9@5%xq9wRLD^ad1)iQj zWZ{@>c|yrIMC2#3U_=zKTLEfa9bV$+M)9}cn$;fu(^0%`sf+Rk_v-qYs`!ihpI;Xi zH`z+B7l@W*OiUN#z9Wfv?9siMk6MWGi|0~36Kb0Lg6k@;A@%Ltqn~uJ>T#qfUQhi7nr2buFE+yWSykgyE!i)c-Y4O4ZWdE$dIs~hP*(S;bkjOvcQmy)ae>#J^ly?8* z*OdYFuJ|Fo>#;oFn=4*ZtkFl$A}cvM&g~2anU+=(#s%3`6j)(_4tBES9F8r!zfa<- zogLq4-&7%PB(rZk69c|?XTGE%Y?=CSv1t-Pv5lSv5JY7M zu!FclmqD&{8s3|(Dx7J-W17ce+MC7Z`A--LR8g8rGmaY?rk{tc+~oFAXR zO?BJYEquIx<@l)lqs7J2yRB5fjA^PEfN5Sj5F{?}e}V$tC=8&A*+3)i9BhyG0DX35 z+PKn@87}@;&8f;Og^u-*$(!4M!%aq$XE)!>*^ynNLgp$%Lbzf^zcLX`3n2zozs(xF zILrg>Qr$11h^@6JOO&Ak@i5{xD>PEC6D>>=n2 za9@PG)O{U6DsU-q-zVL@ov*ly9C7)unKJL$A9o^K+ zBoDgdj-o%*lcat0ylNiO52CU@NAapAGU#OrODLar)Gx^Wa4MpNr$F+}4Bg|Zg1*n*`SbGivVR(d!gz6MlxGQS-*VIG7`qKV5QTuOL@GqVFH70D+*$f!} zsfNr&bN-X~B+^m_!MldNr}dLpris=?)aw@8Zk9=h1eFZD14B{Y<)d);KbtrDUb=$;hFTiQ!T_arU%#~&; zQ%4JI@*!^>>rO-w?lG(FTc)!7I!XNLsRkmYaL;)E=~-bkU6R#sFIo+Ax>uU3V7WrO zcv2`Mg>!BFl@)W{fNsjvfsppo2TB}J!vqcsYLvIaq7`h|{^7R7 zot4?+|8`P4|%<4UN>LS(48ogd^ba%*L- z+j0FU@EH;{boR8@Df)T6eVmJ-8xPpk26Mq?>;5eJ?Nx+SS^<2guIh-m+6eErC+%8Do2ieRF11L(}=j69xD)R zMfXyDexRu1umP$(ydv{Ih%AiP*mMW}k@iPahI3y*?zAE!GR> zq1hXGgN!FoOAHs)&pye@Z&~@cYC9m0!blONswUGx;{0^{wsG(g3?12-h-B-oaJUU5 z$RCrUVT4=I|C6Lz8~86$VcMo%#TjL^xo#Ak;v|w;(WrbJ!yoNi;Oo?tRzNw&J4e&p*uBy zi=_27A{$Xw#jQPpXXjhu*+A^&{|Tz&n^K_QQffOr0kbiR_u7>zT*ggRB=B00j+6@* zSUdjj=eEJTt=weNnT>sfPD1BQ>zYAvZ{%SdYuK)H&}V7)akCwcf9k6 ztENJlc@U8U*Abi)=|tb@FOnQeJUk5ADZv4VGXcBxTV`6?`*<0QV!PeQyVnWs2<}Ca zhN81`@w_{f6Hb5hL1zIA>Jj@(#1}p%T}?H|J89jJgEzW<;?IVap-iMUQnDz)d%f7a zGVsT%%OA7ya|#EqC}}wjtR@sPs>snRm}!=uK5?LqRg`r?r zt&r8A&Sunlrk|6i=`xZmJx%xb6MC=ka`}FFpi<42)vQpA|C~2ne7pY9fmfP0#h3`2 ztb^Z9dQ$1x4&I!}m*JAk|123(w*6$I^vQ2Un$jhujNP)PtISGDzk)+T2+mB2I1Xv4 zNEm_~i&3W&hFv6`Zt`Nkl~58+uOY6WA^~4Wx(cnfKPmg{74rFxr03acw)ez!cb*bz zjpyc&A9mzzo!nIMx%iWlxuPsgCF1s`2dSS}xBfzvEbR0q^!QV@!pE-< za%{wz7 z^ls#n4gMSgdck(Ea`^S$9in=nw(Zd9=pjufv0EW?pEDj%8^apg?*X=PM)Gw|ZB(Cuw9OK|{a|k|vi*7vsS!ycW?em;9|E;;m zB~HoRc1|cG5Og=J&DE%5C<8_N=yA$GF&id<%QvC7tho8bEugVc+ zAeZQ-5MIBerzF@V(oHty4Rs{9Ma_fih5&l8fzn&NJ7)KqInB!TM25A}dv^sf4yZ3E zt)AR_G_=|T+}zxOw;OdqKog!^?zK4bqP3J&<(Asw&Hd4f9hw@BvI%G8;l8+APEX*oawMH%7V8!->Z99^uJEW*&f=B zP$^$W)afUOr##o>@~0f~4hmr2f_A>e6OB(QZaPgtC~;Q#QHJM9QBed7&^89%K0^bM z`LjZvfr&h;Sujv*tH`_B--M9lKC#lNg4pqX{egF^bc+NDZ~0?>lDwv*LRAxEPdA)b zR7$=vH&`P0B0VRE1D{PChS%099w@{`L^db<}pE{tA-Mn^-dgh(~98+4VZ z3O6(A+{pgHX#aO4YNyMuyq10IW$oSVm36N+7ZbiccXu%CZQ-l{qH!+O-kGJ>JU_I0 z8vTtb2vtqgIGfDQ(e9H|dG7jl;m@3rV26V5q#s3sw1@ZZU!bw#V#XFBdVB6`xKA#~ z9qZ>(?GN(<8VnW+;?0sOuI}gXf>QE@IJrZgGgCf2D0X|jMi_Pa zY=f!tioN*vCfUcZ!)OzfpqZwAzAm3i!yyo*gstilP>!voc zc5dL7ck(@!s=0ANjx9>%R3U>~1UN~==%Dx*t#%$@f6o)#>)KGRD>wyMF$UwAON-`K zA||@eL)CfSfi%mzwuu^mRudytMU;q=>Di+xW1g? zOc_2g6XdaYjn~(wTluKs2hmD2KH;_N=H!L@(Jm|uy}hC$p;zhI5#fcSLmZTD#BcvJ z9I6kfo!quhjffhV(|WeoO=96!%-t*lyl9N(T zMP4bCCbes|Z=kwau(QyoM)CGVk&%&;k%c8sUQN>@sPS+hIUk$|IOksFZ+!3yRg67! zuz->&CGNUc0O5pTDa%6nO^m+?o4iZI`HAD-%iOwlM2FG`8YI4KDZcx2Si1GzQkakc zl1l+1zPxpogX2mL-E$DuXu#K>A1q@Q9&9aBjD=Eb`j z`Q*zpz7)rA#;G(~`o=RJ?__E7%Sn1c7`@))Hb$CtR*Yg~D?+R&GSPoBYW(;aj&?5S z*k}@I9HAm3u%~yJ_!wT?(f0Lg#k&%}gwcuZ@gU8o6LM)FBDL5X_4_<0=4JXuJ2xUh zbPZIl$D#HBtX61jwXt(k`?bzXc{6h75>F1)gKW!v#_@-Yi0J}xmH;=6b_#FaSI7q9z@Bx+jAK+IOfC*Q1AnW)b&) z)R7%#56iIk*an;mb0&IR%*W#)( zj}5~fjjA})uFr<}bS=~ zeY4Wbw2zdg? zj2l?*ym z+7ZLYxa(C${qI_@=JXGKe}36uSTCObs>fNyiN{{gX3ef>G}#?!IFfW9|8Emmg4!$& zID+>AHh739j~BELc$g0w(QmJliX5kNMkZhY8SMa7X24e3y!%4s6`GflXN-Xaa07j0 zt$+>u^2g$Ez~$Zx@X!|%aZoRyT=}XenJ{H`2f=6ThVGkX(NA5?UmP{<(%eDIQzJ`^ z*XU@iBxqH;yq3nI!KCxaQ-b^iKTTQWuIQlg-94F9Q{UI~^7mTfSc7dzJYlxUZL`H1 z0fk5H8W;$(Ay4MfDwkFeLmH=+n>P}G$i2r?;-^V9iUF>8;p|HBIAC( z)3p2mh`4S+^GmP2ZcBG;lJ$~YobfA+S#*dLZLW=vFD1%POzeADpOt|l$u)HFkh zq9nZ*Z1-erG6O0T?!}*y==EdSQbVE7EQ}=rl!Rl|Yi! zO#dPw>$BkWJrmtH_Z6ZVw7ki~lH!iR24|bv2HoOB$LCz}#QKH5QLfWO)BjeJUzvpnL$e zZw~aX#d7hy%5vwc_D1};<>buTDlU4=Xbld21V5O`AgAr1qR^Z=Mv0mul~^H~lUI#C zmH6!KbTN|548n-uB}7H3wj?LYlqvWG z^sL@ns%2gD6ewfG+f`bZSphEwc;9$1+k5#(@aVeCvBnR_6S*e9!E;<^;F(MP?9KO?5olRR|@1 zZ%;xi@L@ye%#>xpLxCC<6^Sbm6mI?(@Wa$duYD+rdqA>iSStA@)0SA?*FQWTF{Hq; zT#VPRA?JR|o%zbo;6c4aLW6?H{BHjy6Ne;!@5{cXVi}~EK-hoexI0%Z z%C}dzf~J_9uK>VGQ1d^Yn)F^A371-`=3KhZ(}#0;KXn36fuwY0oJ$|jlpUhNsk!o8 z>L56ESvi5dSacDIDt6($nphYT80c!kgDG&gG!TfDC3u_Hgf2iluCOdms4D%+A9ZOk z`P&Iyf!kB+I`Q#EE4bL1k9Jz+R^Vr6=p<_Dx@JXIYnSUsojoTv=K9b79=CQ_*xYEv zySh@1-APYeFihS^e|FiWh4S(kW;}53tLFjg(xQBn5#3J_ebBU>ukl*6pEVqUkgf4v zcJ?WJT(P<7|bOPq@eX$mFLPLz{|1>>hEYzS=gI&b#=8zBDi7!Tg32b(F)_AU$v!cTCV{B~3G6SxQIQmiUCv#!OT2D4(( zq+|gec8qO)=1B&w8bHW?LS(cSl+@dgP?`C!5<%IP`i`}5t|ym7LuOO=jFnaG{=Pq+ zZc2&QWQZvNj|1L8uoVV2A&mM8PqmN&aHJO>Q$_DlDB_ue{xmjwS( ziVg zb3DX-8;sPc%`3_)XGHZK$bh7aI;wB?<6$)!=aTMg$Q#R0a*Sx=9>c*!h5TFuc^t@WcchoEyWdnDi7lJjziXOHT7V16$dx`K{4^8 zOg)7^he6%T4_=U~CQ{J<+`I5J6f1USTq#F*C18(|jkU*SW%-L2lB#rcT}kQ0=4>BH z!0yIsElb|+#%!qH()-r+^$qY&!Y+_HJ?%DET)U*!QS@UYk*HVU*t_Mr_0DQ1j?iCQ zqb}4NG7h(L;vTablQ7us|8PksFQP4tMAINQuGnw*rSretLH$r^l~c+g~t6e$2IrlIfzk{0QMyudR~H zz9r-1H}1i&fu#)xzq(m*TDkbcmLbp>a!$oWoS&_AUzNOEx8c%s`&}ZXaLTpvU5sg0 zzuC}CThK9;=bKt`o*cW&-#WuzC{>?`ifYRPAovdW+wd^@L9tpIB$dffYFYXB?o&Em zo%kPiXDL)}g>8?(4jz5xO2mtItYf7o-_1%9#Z-P@zw+t9@l~*)PDGq^Ua3821-{#G z*wS8pt&snP`h6&r-BNCyyEo^)ju0OqI9uiq1&DF^;g54pO(Ow<_v44N)8cD5r*wVdxFs^I=U%YrG3?F1 z6?+Dt!jV(Hh#oKnrh<34A^NT zJemAo=`g$wsO(d~aU6f*#Xe;?Gg5K>_m{L#*o&Li*P(WVFO}^wUcUoYaiKfYBpj(f z2v-uvAFJFI63IZ)dYN(eNibeFuD%QXv}Q>3``Ai9Clc|mth))8%{r)+@Rn$N8Kv2& z4U`fjG)6cTs1<*z6D;}r?x!!$4HP{D+cOD1#rbY6%zl;t2MZ?uxi1;m_hAC+&uIBd zGzx6<8Su=o{#OIn)-%a~E;Kgjm>tzsqmf_3yzIzT@{AznNRBC~K z9kI7z`QL@#9nabFxRORF9- zhEWmn-RfBB_dMd0V2(~EF{i^Et=DoWDa|@VxvgJjNqR!iGzQq@(N^c@X*KS-FRkg zEY;oP@K>k>`!N*J5s-)P0+)WNRn%dC0$3(G*relN_sW9F2DVUzmk{On9HCuFF@Upi z^;O5(h`uNC{~Dg!PKBWfsSPLK)}w6~OcMTw`atE!zP7ejCTqd`4}^AnMsC%;lr?@l z+h=5Iv5fzke?g?#YV|Xx&A&?Q9DR^uP-8nSNfLuXXj*sE(DE4s z7Rxu(S`~PY&GVFf5#a5M*82nYT>R@?IT`k)`3jp)AjuIXUKrtZ5=E_WVruY@8eY5| z$B`Kds$MG*(I8CJOY|5KX2FmsiyX7~zh8Lh6uI^xy)g)T)ti|D5ZFX6zVv<8ys7HA z@jhQ-%m@;AR=e#^KJtVZCys{-;gxLVrJ%Y~)wcY=lA}au3;xKRuJ%DXDnX1DnHvd0 zXg4Eil8YG1hGw-SQEzttU1<*gTN3OaBoLlynhO2hH@)WuVyF_YcQ=2%RFdCg6Wzp& z1g~61Zelf%zhA6m5#>q?L;Q&j^iH*ytE@Ej#*t_%G@tL_K8lyKJx)H;$}dY zJ51WtlQnYLK2PbHOHzN8uQrd`%r)eD*1r4S%iy>~rTFLhSDc)=NbZ*69W8?t%E85l zmhWQa3D1(E7hG$vDs1d~4Q>ga$!f?WsAj>^{_iHC;*e^x_H9S9)1piK4;6CJ!}XLZ z*Q!`VWEyy`Bkvj;WEC=RNAG>3{*05OsUct@XYaz7Oz?WiC%SXgviu-HLN$>q)MO6H zB+e6kTRri|B(|4_eg3c@=!VagHtn1a?rZfZn|~^F2~Vc>Z9C#4RDP_&*F(8abW|~Z zpa~nL|H=JzBO`K`J@>r~lOZYED3+_BEth9Q<6{42iY zy)}TUropPg&1vLTP2{N^`UAw=}c&!Zy z!A(%c|Ith45Aji_X|_eRpIaX^jJk8cg}$M7?P%~Qqm{Qo$RVup&)`wy3Hd)iI&hhV zG~fxZVMd(u$6$dHH%}3`b~xIhh;&4eabyN}Pv-CAPyz+v`Q!^nQQw9PB7~A;65lY- z?=mCBl{j)WTuI__io?DuXOd^N#&?aW|E`Be_2FwWRo&CRT9{XF?|Gc?Yqu_K^+2MI z;X(&UQM+l)x6+U;i6n7t*4@okf=aO9~u}KZv@~ z?b}8+{g%i!YG(SsusJnDGPTE_lys9qv@=38bTw}jv9FxD zZlFlOf&PF{5GPFj_lt?VMBOA{FUkJT)EZ-F) z%IH_W{`(X4Qv|Lu7*cq;`?)Qztyf?7%=c&nS5pNmm9Hj!Y8ctJkG~i4*L~R6(iv@CH2>3Zc)_RK z0MWZ+nk)XpmKdWQuh_0Ul6&)r{>G7D!9)Izk$pm|E@3x|7DLJ`H0J$(;dultAhOg=x7yBl8!^gwIq+ zHQkqDHT)kQGEw1#kUjh#rk;fpS1X?LE>$wEpG|*R^7}-$H^ENXc9!i^<^I5Gc@O;r z8)%M5t>SMT7UHGw&yzW90hf!1zM^9xLD^*8efOwslzEvUyd z1Xs>Qow!v$xcMT+T?kWxN}u`f(n=x5D6ni0C%vIw_K+LP0^iifQXx%zho;Rn$}*HA zFoUmJz^t#F<4>A=a4$^n=|om?|K*|_`CZcX?W@E`me*gS)7CDR;cj|k0L8m` zhfCIQR_A;q$H>H)wf*E<)`&Rk{-iv7U^1UIVoMU(z^NdDs@!#Xw311ZgLrkK)?F!BKkM}BNP!CRLpu6OL87q zncWLJB3+SVErq)otA#B%yZ4mrH04Wd-CSr`VJd~nNDCs~|8G0cb|=qvT&)^`y@R;# z0{SB5p#|MDwM2##tKoK`ZOcvGhtWkX*x5Qt_AG;OY{k*9M-50i5f1&|jUo*|ru4edNY})uIJArU11+ z0egHc^QS6n2}GnM5{xkV;>C`?#+?f1JLr%b*roo;U?o!n3aj&JkN#y}-i}yU{xOYm ze>5`hn_a&CeL-f+QSCm(A>Y+4f@)f<6SB2u%m~Xm z$MGHwRt{whRTQ(fmO-p$EFF<=;-tog7^ZSihUVq_-&)q_cbl~wvw1M|0zUxZ(*yO_ z@`SwGO2E-U*NF4J6-5xd9Tu`kV?&yZ4SEYpckO{!bnT3Sa}O($H@}-y(j(0!@LtPb9$0b*rmq?wY3IUodWF06mV_UDy)_JK0qZtBQDnp6b6#mqE;@9wf)n8AKs>^O3Pr_F{ zG!{IFRcQx`0<8t)&(7;tua4oxumDk9ywiMaVZ5X;Q7b6kt|7~|;Tw}3G>?E{#{ub` z12Z;OY|rYpFN{=>-))I$1duoaw4p}6n&PM8{%iwT;|l$)ul9%J;R**vJe*PW!u?y| z7Ov}sgt7aOg|=l7MiIUyF(<^VLx1+>_=yvEy{Zo&h;ISH?-OLRZoE-8S>rJqf`@)U z_FH+1iSQG&-79!7zo_cR$NGRh#}Aj2BoQ&EkuVU%{{)=K+n_b!=4>|#WJXb!?zZ4Q zZ2x#TJJW5pu5oP<4`l-Oa56*EQ?1sI03jnIy9kn1iqq0eB-Aa`zlZ;*!$c0vQ7WW@ zRd#>n3jV8MR+m4)Xcb<5{$?29wAyox%>r~`d97&yMb}S!J6v=LYQm#%py?*weGb!k zsPT`{qA7ygkt83_9h_J+Z&F8OkU~5Rk;cw*i)W2j5VM<#VMQ1ip24<;BSpG6!ndE8 zsi~>q1|nQ+Em9X zfHrq=*kHF3tF~03>v)txQ}z!bo~_yl%&jC_o6&;-MIZx>2kP+z41kLw9|KEV@BH@% z00P~G86N;NqVYq(096_GOnY7GKBX1|^AO>bHa^6Pa6Hx{W2+AS%^!Rec(7*Dw0zg^ zk_n79GyfPy>H|>&jQ+pk|A6X*dv@U7g)w-h})Sk3ZU@+@_1zylB01arzMN zYU#gqdjh1**I>dLftx0N1{P@fn?N}a*e!cyD5V23MrfV_IkjB`Hd&oV2WB$Yl4~gM zD0#;aS{Yoi_DH)~^NbS5fViM$rtX=9Jri?c!Nbu_6>l)xoceGg9TOmp;h^MT1M5N^ z&va=nQ(JpWCAo5c&Y^?c=;Xb?gB;pZQ)}mCxySE*E(nbaePc z%3I)v?>x+op^eey0)5;QQHK-6)Td+8glz~^0;w$(y9;iE-4Rc=v?ZL`f%_Gn!uo6Y zqTIm-{ZhlxVf9PR*SpLO2V0|0fQT~nTNE{0j)tfqg*kayc2Z9+{88E3?wcDQ&UzP0 z$%gVlFdszkDKTR^%kDe*r}G~6r#`+PWECA^#Rhe^2bw)FJ5y6RhA9~6);5tH&Bvtl zXK`R2Yz5BPzu*cC>A?pvxj;372VY^%*_@HdKI|D`_}_zBc`d;7CwVp=0qz_O!<6rW z>Ynu4Au9Ppv7E+{e@%7~lt#xv9$U4*aKjy;F+5 zGIUtRd2<`{c$^vhX^l57fB&_&9KlryyNZVt(?0!uh=LiP*G8RdDM8FFBCSDrm@XZ%1kMipi9*0i<%H(IhKK`q;KtO$Lp&J@ z2O;8sCTWt{9t-8Zo|62*x=NTcV-emB%&pBtjK&y%oT*_aU;ibo;M4%eg;78ZU;lIe zYw>FUHnV_n=4;@QJ=(t~4V(_2j131k-mXHxhm4S3Jl($i0^bI=X-3~MNxD1vbH!5n zGb)St2$5O3mJ)B10p$9Y82aNYjai!Nnzv4)V)%JlK45cu>$mOPP%!xnERu9EA4O?wJv#5V zebwgr1D2JT(=sf8%5E=6zk&C$0Oc5d&wC3pgX+OhmRAq*Dw{xYK{?t;8IbRwHR|!@ z$t9nOaK^BdG{rJdX*=JLa_X4>~n-lkz1l`P(o zWAv4K?IXD$?pKm#3kHSOGB!@{QE=iA{IEd>2|f}(tu+-Vr_$F_1w&uqPWP>d7C79y zIRRUB6L)kuu9h>qb`o^vjV*Fv7cz2jTm@P;{gFL?m=0qk* z(_2p}}Lb>=oV2BX^V~Fvt^0lk0HLQu^6gW2| zqY;-Wq$K(2)${!dEkF76jWFB6OH6g+t}=vD#Jo4IB6Y+#b%gw&=)rriMN6->ak@DW zhpvS8wZRC>2)Vt8=_H2dl zZW=zq6_-h|JHDkK=VqL1#5cd)bc$Un2=V$}eF;3c`#@jYZA#!rJx^?hjZmE{Xy8;j zIIXG|*E0_fi}455**FA`wsq#{rYG<|{>F4mikI~p%HE9e@L013O3nKdVoG?Cs{ODk zXuU#A8$=Q)>8_Lu;bl!wh=%zGQ${w;j8+LcgL3CviJ9E*3YdKv z!zOncRw)-j(2KuO0U5l|ahrs8>BF|^7;tJ4+s#m@#o{Gy{Lkt#{N&|e(3xQU4u_=o zQVdj;RKKy|Jyp?4;Y1syEK?Rja(F(_%DCzt{KB3O7{O+y6$)?|M&eIn`~u- zM9RsC?2*|?Mi~u~j6(Ka$&7@O)iIJ4i9+^B;V5NhM0O#2kL-1R_w)Jw`lHt?^qj}@ zyx;fxzOVbbt{bcti&I|^uE3FTW#}%5OVK`G%V7PST$}s}Tt$cuQ;;?CnK}dKF@{sc z)4STXIiD!CrMZS5awqpZPNq-5t2EPfvJxEX1dBn)iRQ#=El>lpq5?q(T7v&C}eknT&>2s~V$d9++dJONTeCbxDdT1SCP#&(SrK^wcUiLHp63XB`tU>n$e;olYG zWv27r6sX{hg~B&!&gP-43^1o%fo|z{@|wAMnPC@FNM=MGHZ@gMF;LJCyEQ%WOQ(v6%briEnDVg z51b9ZW-o_#Bw416`TWYP2!7>jO-j9B5>9~YIL;n%t9iJw0<1?lQ{#LY2=908IXmR% zg?T4uF1!A4;~<;Wu1)BsKQ1#r`8;YPOW6mG$prP`xU+^IxX@!h#q~RfW;Ae?&!HI_ zq@Uj|EK4%=gVz7?hpnq#W;uV4(|_q#10mprvwbZOos?k_wqIjzu=Er`uK=Y~O2F={ zX6t*ng3|UVDN0OJTXO;J;_!yz-E!{r^$o8<`umecKgf}A+pydayZ%@5FH9J#hU_(U`%N0w|a@!3f@YaflA=8YvULM62>?^ zr={VKkmP>I6Tg8nZnL(a5s#AYZWWn7ig~zQ0L6LKA2ym$7gUD6=`jD|b}-{;F6yug z)F49*BOot4DVTo;>=`r_=maB;XuGKb4vG62MMa&S za%^{8Fz4#_-G-L!!JHqBCwICxXx->&53n*GWjfT z)Iw>hfF^L>f7b)8#cT)Y0SEd5!>>#d-9eZstJOaUpb)%1?k9xaw@1iE5} zmd_`DKWt#pl|8)H14%qDbp&b?u`pEG;VB0xRmIT-F1Wu3i{*VQu$jjpv%rDZeXD7= z&Z2G@lhP^p8TAkfI$E>tfDgx|>_#DHZUBbx3Y4P_Wg!E;!Nbx(G|tZ3%G+unWw|BA-BVzIQhP+ z_b?@5Ay2^8@SMT}^@1 z$7neOdk3@h`JWD#_pqyLV86(0h7F!@%JmttL(4v*P%v;RnS1Yv7nglkOD=eXk7*O><3as$>3<)K?qw zYpMZh`>I(X#CDX*hvpqsixoqUD?)fs^ae11l0bz6BOqMayk3H4xH-P74(B}zi>R0pPU&e}x7QU-TyhOPwfcD&l zU{f{w_7w_N8e38L^bB1AlxTeJH>_!t9Rp(PGjNK;mT{-}nbr+z@(2r5Gcf<=0q+!V z6E=bCk8cioxwDkbaCq8D+WsU11VtXs1t4%$@s4d|FsQYu?}P*t<}PLw7y~N`<)htg z!Jn-HhMFTWfa*Q0#M84>bxa2xox(Qd<8j*OTumr4g#Q({V$+}Kn>(&2Z+hx4#$c;- zalZSI3BBMpr5(MA9Z_y@D8Gn{&?tHtsr+*Efc!L{0zKvdGr<;VJLS!ZuUFp!!>_wDz1)4D_1KyV~;YI95_B5yvvGT=G0Z$IaI}+csA9=A}^$*c=$d!hW~7X z2uTEjJbUf%(OuvUKlN~3ptAaNaUD@}fm1}3wG07YzCgWA4hoLj-4FSbA7zw-O?dvT z;xC^5$&$9gFl`lu!;&ueh@bm7!3=r9$8$2j(N_uE_Z%uT2bdxQ!fnN1{cKYm4;T&y zHNOxruI^3g%f2)-sR|RzAW%fxq{ZET9dIiukHxnL}k0(Gq5Dsiy3#d-3 zW-^~S6twCznvZC}9!5PX`~dA@S9~aW?(>RT<9v5PSy-1;%+otmfK(8Ohg-@+$`S{z z5V;=}`Thl{q(btSV%O1a$S(6u9TfwA1E7p;wm*KI5ZrOKFY?ngdHrb~?rVf^FDI^2hLSJQc5< zpb2IEz)(3KDbX&b7@g{6{P|Yf);9d`D4b-fIXbB&hqb7=OuN#aL&s2k$KJhx603yG zXS!aBPuVvNQoROk)mn@|STkvnC~D@k<9#tRDoD~MELN}9ke0l1^HdAf2V(FW*^|~Q zy7*xOQ=PR2Yk##C-mPHfVdG*bvw0``2Zj_l zJ{g}+X6ibte$U86*?H2Z5IPpay}NEAiy9xBd^4d|fRC^CcsFTr>A(r__;`@4k9!qW zA9(~aD|I`Pk#Bmnmxrk)eaV@us2T%BB(qkdTJJD_v9IG0{h6jJWx>x-^Hi2IJR z03mNIDT4dl0*0=b$*sw7tl043C09kh!l5P6yFPrCy!lWGN8#ou0e z`FHm*xXE7z+NnumR!eg;Q{06`6bA-)o~);d`;)fdx`c2Qji4vAzBIiOu;V_NqJwBo zw&JVSaAO3T2i>cGrUTBmUPwQ?VA;8qWdV-ztuTGFMI${uU35RqrQ~^9EgkwkXVYS= zP;jl-q}G&iP&JO&<3e@*ksyly9p3%lKkw*=6`QBYG#e{3*E+P*x zGYG*2>jxkLVCz|9uL_(FD6z&35mWaAEejx6fU@zdkR0vsgz3p*R73=ve#C{%@@aKI zc^SuafjCVQ;2Of8oxH$-Ey-cd_sp6?r%Wl#_<-I{-ol7ZMGEOXZ)T=%EkD^yrGF_| zPp0)lO9!xS7M()f9-~|(oDR`&z=#hI%)w~oLv{c^eu{!rZwA_tU%bZR`7-gH0*x}Q zrPFhwS{@Th_iLhg@R(g!AH^ z3?D$QMMYg3SyAGV2jVL$qO;Vt_%2LNa9}c(sSG~4a!pvj?qW@q0Qjl1Y^A)=!2=q0 zTJg;9LDfRwIzCuy7C>Ra1=&gb^@kYQBWFTLad-oM6F-s5>CblJV#YUx2Qw5Os^ysw zXg4GFnI4sJnF|L^BkPzLi(ZVmjO;IpGnwIn*KY`fQPJ*(bSy?PNsX@jO)FYEJ))JP z!gwFaEm54&4eJn{^R|J27mCjky^iC&J$qEbj&kwisn~|ucRd#NzusGwdNrT63v-c; z1oZwms`Sx>Q56)J8A>)|N*4Xm8S5U-0H&3{h0n7IO4XanQ@8R;DZ@3w)v(kgx)`yK zP>xjr+1!NkEh>c6E5Frv!Z$z0tt~;8k%Ot+a-wQHisf3A0bG;0_MB#1wtmM+&!_q; z5!I&u^Ar%t^8M0GiwLFO%N*p5uj6c2SiTU+AwdBzZF>SLxm1!-Gaz)}ZP!=JxvnPj zu(*Fp!(=GGiw}VU|4MM+{YpNqnr!48s3%*#0TlNw2(rPl0I)^jc7By8Ear;NNq7q> zZc%@&qJN_F+pjYjHUS-RMGtHE_Xc3x!6lsM-46_DgP!1h3rN z@QzVW1xO9Qt}XLr>AP&yLv4-DXld$pk_LD=1b)y>No1^WT<^dluGBe9~lZ^bKCcoc_8&V1)&Q|pJzqxT! z<6;-BFg&e1WeB3*Y$k$F0@9=}IMS z={+?w8*OM!$A5ehP7eOz0K^|^oz4XS2)HYNFx&+F$fqeO6M0X-6}1vryMRq?NeAIg zaaK)io(Fc5Q&Xt>g@T2i%fLrdX3sxlJ84BF>z8}{$1f>>VZX#r(0Jbk$9{YI3{iaD zG!AcMdGJ-|!h*J8bt;W|bo^LRSV;Jt0jdl`*gZoBdn3fOLm;MS0rE~gVxIkia8BTq zJeL%0MaJ_@&HT_yMOdBmF#g;2+gj1 z+~8LC3Z(Sa?6IkiZ%6_M$S&)RB1cwC6&Jxr@6ePbHahr3*4rOu?KT1`!_pKu{IwB8 zb`dPG7LQRm zFQQ)*ubGN?Z8Le~&5!?16Gh*AlyW7vwcQ@IP1sGOnpBpK(C2jIROb2{&0Ob);hQ|R z8;0FB_M~1^jM;a{2yDphG-yb8Urkr4`7Z&1niYA#l3`cJpZza4{Y5Na-Q{1O%~>1d zUj7<`@jb%II3crIzhU>;-hlTWdCr1lFuLH^w>;l3E#;Pzo}cpf55)!hE-4%Z%2vi{ zd|hL`Y3oJ2)qc4Ci#FNtch6Spu&QZGDmb-cr0jf}*IvM6*5&9Q(2?tdhJd167xcI< zoK0-fHl=WSDfTK<$iV97zY8?f?EYx71aEXitT?KB6x zg%XO@v=V*3ry9_3c}K#T-!8E5z7bg}PbnEHB;wsz%e*er<;JS8bIh(MH+KCFbP%5T zw$B{Dy7}qr_vI%Ch$%%UJHcM>aEY1e-NcNJZ$kK?eLX#rxp0%ps`WM8Gs$RC4Y_K{ zlr~KcNA-qs>q!BYE0;}03#o!L&P6cAmn)gL=_WD#qG$CQlO>s4wq-$dRd*D$ahTYr zmVpNCcLla7$v}Ut+b*?(8*mf~3dGpZm@|@jkR3gCOfI0%>(xid-Kz_pih0#G>>Xr} zI0uj`&DrD}e)b~O6-~gLIWAY$ZHfjp6Dh-M@fUf+e~u{?0w<)cF672E^a+PrTJLVk zP9<5yrJ+Ec>vDHRXqUUXBYxETD-$+Q`IXv^Z}l&qR>oL1Nr@2?ZxW5;5%jL^^de(* z#fzINjQZbiGh{H}&punmxLjbk-Q&>(H3=9m;g1JUvO>EjQm(E$w*O*SLr1TtyROar zi8sD{#v}(G22f4CY2JvOS>`BQqW-k?w(G#bikblRwe{I9^OAoYWnb?i%NIFTmq{m= zu}s;#uS!XoJj!P!_!LvaZ{V~1V9W^ z9swp*O0A7&s;Bb#J+sW$va*B+HttC-nkbw$_22f_<-a$X=HfeYqtKKaC=xvIer`U_ zvfp10UGt6$|M|GfD=>MqPB^3T8vQ-@SMq!4y0-?L5WG6Nf#g`c_{ z9>HK9sG(wikuY*D^f#ZzH1tYPrQWkl9ocGy*EBujzxxoN@XI*QdkZ6?_eqZn)z@^_ zokslzQG$>V2vh+sn0%tvJYzPRE5^4L4<6aUqh_s=L49;Cjo%v71nmfw^az(@N)XbI>5e45K%ZXq8ziu{Mngm`z%8(kxy7x7`ZEb{!cKPUKtIVtc6krq%j` zapi}C@gGWwY}^Q(xh34&oCiZo*0jMM9|Kav=Gl8{?{D~s_7oT!Ho4J>$|C?-gG(D| z`yiiK`uP%|%TZ7Xse$sB9$EqP^(Fuce|L-Hfv^XzU%iP3`eF>891Ai2bIJD#VHLH49K&H48(>tpnI>kD zou;ZmlItd~aE4R9H}ZpbUPr`@PhO6^iza#}1A6zSe%zzz`LX0HvQc|}k0Zrr>Sfi= z)GOCP+Z-^`Wq}&`UffYe{g?)_5qgW8GIdqI+Cwa-pZomo;6RWzcZ|*X6@eDPJGq<*zrf+cj*JOuE--rUm?#-0_2Lq2dR2P zs-OO;$Z~xLJT4j?&kFTx>uZnu(Lv8dTq3ajWDU8ln9uS3w=R;HPexm{$@wix_c_Oo zTAPGpUFb=-sZ?5_)9l50;lWBi(225u7yv$<7pyY|q;-L?rLGgT?4O2L2Rjg;D2!I8 zfUZt%EU_=Cf~b*p zrP((xGwhMQ3=zWbGeck6NL-T`14DnLg$?=)Q11aPl-1$4={F&>ZCwgf-DEo8LH zdE+w&g&!iKqkkSh`}^DD9xm4TFYop_?^(y!o*#Imf2B0mVSo zc)0i?za2MT`x=;dP2Tfe@zdhVccn-^KZZ6KM2!NU4(SM86(A}G_`>S32|W(}M(*o>2I1*QydRz9)0<>+a~z`J++ zG_q<~Be4GpNL-wqoR+Ae;W#<|LO}P=HaJOV0MYU7_2lOya#{=lIzFtPzuR8P=z?8| zSl}VFWc#VT!f#~CN8wBwks_NeXthc@4)(O%4a7VEp*%~5gB9pgu)esAmuZDBs|(MO zch(t5XBM9Q>hhzm#r95q+DJ?B+mSb!p^rnVrOmo?4YrK$HY*Bf%F0lis1bCj}(+Q6q(iZM8d7obXl~JpECUp3je*c(Gz zi$hz_K%>bsJ5r|%n%5`Lo*rJ_Jq>9b9nVYbZ{|;_AdE)xPuoZ^LrV(|&u%M0FVu?@ z9T=F0x?i5F-3H^0*9)GIzrU)zFSYsyaKFcq!=gqd{7Y$_BY6e^eJQYW)OC3%R;Yuz zk(49mTvfH{;fN7iStB{oN_6Jl%@tpzs`ufQU3J^Re`+`*TZtfReoc9_DwIKz#=JuTGk>nW+im-J*gl~0B4qAdr(=8i zyn#yyZUr^t4^e*#VC5Ej24qR+UC)V%xw2$>e&|rNE96=pz|b~7-$X2K%?6sn%wJbe zU36L;%zcaebfV`aRmj8!E0vSd|`H>E_Sr}g=n5FqA(Z?o-An$ct=OCVXrb4e;zG-13I>!PU zS|Ix$12b^1gg~gt@dO@pd>-4uvRa+s7p5N*k%M0fH|0QB|2V}LKyFmV>`>f! zOJ`Q+jXP#_EW>&nu2zx#4AAYo#vq zVT8H9*Mnlwx4=?`KQq6at>sEk1u{s=PB9u(D3vL$xm(SCal-S;{Vm8@4Mv{&V9?HM74fCLFe_O6i}N?wD$(Gi|E0@!BYhQk%SE2LM_E}j zLAIR`MHi|jJ02;q0`yl4fL|z=fk!w7T54N{(HqEe$iRL?6U7g&z=Ym;ZF#5m*@+r; zl8r=zA>X?tjwDR6Htv}9U~!?*6UblboL*O!37gBE;;QoP0ac*TN0KNvZ$|{^z?OXqy4uY8vx zI_h{Bs7^2|!uF@T6D(@fJzLsoIB6wCAjO4bscM|w4_Y!PQH~AW;9HQ(^^&`A;z#yy zIGuq5@iX=HZC;}@29LZ(5;Zc2c6OzFgMR5L4uWJ%vF#^dGzB;WDj>utrOUzD_JrOt z+v_NGX5CXEs{-{+f`)cDgp$mRjFU?nK>qo!NdeaQf8}XXyb?S>V2xtE1zT@|G?ILE+|7z*j1<<7toof%-aR!75@K*Z<%5Sj7QA_eCIzm$%Z2+OqIaVuP z5ONDlP%%P3uqTPbHD|j$sg@TqwR~yYg)#S(H2WkStPR*@&_zTkT1kh@MSH!gKaWM6 zU8}t#n-K^N4d{vh4#{AEb7uM??3`lA`Yun-kcwvgL&ft^72>q-9cpyT(v6Ak3}Ihu zhuJ_Dp#quQ3T!cI5ikTQ2NqSq>^xwhDv-vzG@njH4RmkQjlP)qx>Fkr#*){^Zz@~- zDw{!y)Luw3G7f92;m9VnCyxL>tCL#A8|lO3&RJ(;Hz2a#_XBeNlWzQvaH?A@R=0H5 z{lH4zqvMA1a+|*i=j+33mGJ z=t=+Ay73MtUX)<EtMWMrKh3gluc=v64+`Eci>Gn}rYoGwq5naIQ7PR5!7${?#K8eG2PY5Nh%f@pxMd(89)p>e^8)EZBjvDecGrC$Yff7y}+N_Y$ z^DBKN|8_zK4JwD-jzB+H6lx=xiV^$umpFf&@)VSE2%?g_81ZJ`X7z)RhdeeLGhL)8 zBMuBR=oIevbmRtJ^;ZI{7&t1tDB2B-T#FMyu^?_Os{QjMyoqOU_TW)MFE?Upd5NEo zIRhaWcU_jia${r;fl*>F4Rk<1h%MW&F+`5xovuGB)zs0U-`!k%2Ekw)%3a9|->-Hi zV8@Kbq1&$pd`KpGB4dKf?P`!Qm+kkO%Yxch~|p zLqI6Bhj-w=0UfVbfpB}!nspmiS|)UfHtizf-6Y&O7&pDa*U+ba0Ku`V(%J0FD`}A_ zsn}B$UKMI6qihxYqIg9`b^CX&^J0~+*Hz7~P{#o|ZXAlGMs)oFu+M&?8z%6iK<*_A z)E#}`vrXz%EbP$`_HXpYhie@DEl*To(AyitWONL7oZZLa$5?SZx~V{aDnJjBJTMIU zkSF8N-+3R}`AOndG_1n6VtUOEOZ9=mhx*BwgSLbH{5_mjyKV2s6_`%U0<9mdyjY?G z!~XMrxT8$;j$T@))sNPPtOCP}7Zw&|vuJtT3GUTU+;4ef=XV-O!k00p`Djp!O@zE4$-3N!z>9;`<={(l7CfOpqKcPujQm(ksd09h)4yHRpmCj88U!<}h#P`qwO4nwkiPwpd>W&}HKYyI;? zw8t%CVPaxp7@SsYhGza!f(H3m9^ZxHy1zC*du}QjMu#_f1513rpNY^1W9`NS_((GR zIF=YP0->7IMg4dRtI@v(;cYcSsTztpN}|JQpx2f-vT;=KcMfr0Gww0G97c330DMju zN~IWEF0p^LorA>vH>U@yrc>YXRrE%QSEGQEBK;TW3wpBD(Ls|fjk za#_%a0HJampuX?0%`8mWn6{^P z5U9WgUYXa;`!I~@KYB(3kYCxnv$EWO&Mx}D4w$4KttlZVG|X5W$AeEs zX8t|z`=6TL5xuOLG-g>ZUd}Om%y_kF)Z0Hi-0vht_Lzzxl1Q@68MNNKoA3=-H)th} z3iY7%@C|6gq2MddCGUZqs)DjA3k>UQKvM!bhH#LMorJgo;OgePDV&uSW}n2I8LxjB zI_d@^Ge_QBr3|Vz%zSau6w31Uz4bk5FmX&ej#ML{Vc$XQ_KdmR{sK3@skK!*kM
TF_ z-iaQQggHHvPBQB4f=`D^Wm}0{Q<u*N z+LE(|9N=$WBC(W)&#k(#^%w45S;3Dno$NWx@~Kuf65-d`%~}zzn-1?R{Ef# zq5Qo$9gN@<^+~?9-{+BbvQ7-asO$1gGkYA~e-lY!@o%FLVzPcl)4}=4}O?_DRK<`pOS1yfL2q ze3|T%n6fA0#-o;4Zn7LC_pe22_~#-v0v{H0$fKve!A6Q*E7k$Y^O>^Z5A#-=3kc+i zLAD5VVRRn#BDXIQP1+mfnT%Z73D*$q46^NKS;V?t_zIJL^1p1-lPqL4EMUQLB@`n` z5yNnLHe=U@eo0N`T_t79`a!lH^P0%BLvJa($PNgcNg~i^hF_W^v7yPJ4pQ$TzGpZm za586;0<{2x@1AcZDjT8WcN1 zuOt5!M%Ajr)o#d(T(+h}U|ek88lr>f@em>qou`L&yUQ#D;O{z!2GJqCr2Xvv`OlPc zl#(994Cy--lq!UoN)8+$5R1bdWxtu>K_-dlajI}5Qd!Z}$;86h>jx!WUO%!_{t;hc z93C-G6o&nTlN5(Ljy@kgH&M!#xZq?xdFmcwA@%4j - + scholarr diff --git a/frontend/src/components/ui/AppBrandMark.vue b/frontend/src/components/ui/AppBrandMark.vue index 6ef1d1d..0061ca4 100644 --- a/frontend/src/components/ui/AppBrandMark.vue +++ b/frontend/src/components/ui/AppBrandMark.vue @@ -1,8 +1,6 @@ diff --git a/frontend/src/logo.png b/frontend/src/logo.png deleted file mode 100644 index 5fbd13eb45890819c9bcd5f54cb3fe3e83644986..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 106296 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