Spaces:
Sleeping
Sleeping
Upload 31 files
Browse files- .env.example +17 -0
- .gitattributes +37 -37
- Dockerfile +7 -7
- README-DEPLOY.md +120 -91
- README.md +79 -65
- RELEASE_A_NOTES.md +46 -0
- ResearchBee_LibGuides.html +0 -0
- admin.py +730 -730
- app.py +226 -58
- asjc_classifier.py +216 -216
- asjc_eval.jsonl +20 -20
- build_centroids.py +130 -130
- doi_utils.py +92 -92
- harvest_openalex.py +353 -353
- journal_lookup.py +224 -224
- khazna_loader.py +136 -136
- ku_knowledge.py +369 -369
- oaworks.py +184 -56
- openalex.py +145 -145
- prompts.py +53 -72
- repositories.py +82 -13
- requirements-dev.txt +3 -0
- requirements.txt +12 -12
- researchbee_journals.py +606 -606
- scimago_index.py +298 -298
- style.css +28 -28
- train_asjc.py +329 -329
- usage.py +354 -354
- utils.py +13 -13
.env.example
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Required
|
| 2 |
+
OPENAI_API_KEY=
|
| 3 |
+
KHAZNA_RELOAD_TOKEN=
|
| 4 |
+
|
| 5 |
+
# Optional Khazna dataset configuration
|
| 6 |
+
KHAZNA_DATASET=nikeshn/researchbee-data
|
| 7 |
+
HF_TOKEN=
|
| 8 |
+
|
| 9 |
+
# Optional admin / analytics configuration already supported by the project
|
| 10 |
+
RESEARCHBEE_ADMIN_PASSWORD=
|
| 11 |
+
CLOUDFLARE_WORKER_URL=
|
| 12 |
+
CLOUDFLARE_WORKER_TOKEN=
|
| 13 |
+
USAGE_PUSH_ENABLED=false
|
| 14 |
+
|
| 15 |
+
# Optional model configuration
|
| 16 |
+
ASJC_LLM_MODEL=gpt-4.1-nano
|
| 17 |
+
ANTHROPIC_API_KEY=
|
.gitattributes
CHANGED
|
@@ -1,37 +1,37 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
-
scimagojr_2025.csv filter=lfs diff=lfs merge=lfs -text
|
| 37 |
-
journals.db filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
scimagojr_2025.csv filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
journals.db filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
-
FROM python:3.11-slim
|
| 2 |
-
WORKDIR /app
|
| 3 |
-
COPY requirements.txt .
|
| 4 |
-
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
-
COPY . .
|
| 6 |
-
EXPOSE 7860
|
| 7 |
-
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY . .
|
| 6 |
+
EXPOSE 7860
|
| 7 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README-DEPLOY.md
CHANGED
|
@@ -1,130 +1,159 @@
|
|
| 1 |
-
# ResearchBee v1.
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
source could be reached, the system says so instead of filling the gap.
|
| 7 |
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
|
|
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|---|---|
|
| 14 |
-
| `app.py` | **modified** β DOI-first `/api/check-license`, registry-backed `/api/find-repository`, fixed publisher matcher, Khazna startup + 23:30 refresh, new `/api/health` |
|
| 15 |
-
| `prompts.py` | **modified** β "ResearchPilot" β "ResearchBee" everywhere; `LICENSE_SYSTEM_PROMPT` can no longer state policy facts; new `ADVISORY_NOTE_PROMPT`; `REPO_SYSTEM_PROMPT` constrained to registry selection |
|
| 16 |
-
| `oaworks.py` | **new** β OA.Works Permissions client, schema-confirmed against T&F / Elsevier / Springer |
|
| 17 |
-
| `doi_utils.py` | **new** β DOI extraction from URLs and text; ISSN mod-11 validation |
|
| 18 |
-
| `repositories.py` | **new** β 25-entry curated repository registry |
|
| 19 |
-
| `khazna_loader.py` | **new** β loads the harvested Khazna index from an HF Dataset |
|
| 20 |
-
| `requirements.txt` | **modified** β added `huggingface_hub`, `apscheduler` |
|
| 21 |
|
| 22 |
-
|
| 23 |
-
`utils.py`, `Dockerfile`, and the SCImago CSV are unchanged β leave them.
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
3. Check `GET /api/health` after deploy β it reports every source's status.
|
| 33 |
|
| 34 |
-
Khazna
|
| 35 |
-
correct behaviour, not a failure.
|
| 36 |
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
|
|
|
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|---|---|
|
| 43 |
-
| `index.html` | **modified** β DOI/URL field added as the primary input |
|
| 44 |
-
| `js/license.js` | **modified** β sends the DOI, renders provenance, Khazna status, deposit statement |
|
| 45 |
-
| `js/render_license.js` | **new** β the new render helpers |
|
| 46 |
-
| `css/style.css` | **modified** β styles appended at the end |
|
| 47 |
-
| `khazna_oai.py` | **new** β the OAI-PMH harvester (runs in Actions, not in the Space) |
|
| 48 |
-
| `.github/workflows/harvest-khazna.yml` | **new** β nightly 23:00 Dubai harvest |
|
| 49 |
-
| `test_publishers.py` | **new** β validation harness |
|
| 50 |
|
| 51 |
-
|
| 52 |
|
| 53 |
-
-
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
|
|
|
| 56 |
|
| 57 |
```bash
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
| 64 |
```
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
4. Nightly thereafter: Sunday full, MonβSat incremental
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
|
| 75 |
-
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
|
|
|
|
| 78 |
|
| 79 |
-
|
| 80 |
-
rights, the publisher's required deposit statement with a copy button, a source
|
| 81 |
-
panel with evidence links, and Khazna status.
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
-
|
| 88 |
-
from "Not yet in Khazna".
|
| 89 |
|
| 90 |
-
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
```
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
```
|
| 97 |
|
| 98 |
-
|
|
|
|
|
|
|
| 99 |
|
| 100 |
-
##
|
| 101 |
|
| 102 |
-
|
| 103 |
-
`score: 1100` β 12 months (UK) and 24 months (everyone else). Ranking by score
|
| 104 |
-
alone silently picks 12. `_permission_rank()` in `oaworks.py` breaks the tie by
|
| 105 |
-
the *longer* embargo. Do not "optimise" that away.
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
Press" from being mistaken for Elsevier.
|
| 117 |
|
| 118 |
-
|
| 119 |
-
`fit_reason`; every factual field is overwritten from `repositories.py`.
|
| 120 |
-
Unknown ids are dropped. Entries you have not personally verified are left
|
| 121 |
-
blank rather than guessed β please fill them in and bump `verified`.
|
| 122 |
|
| 123 |
-
|
| 124 |
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
- Worth telling OA.Works about the UK/non-UK tie: `help@openaccessbutton.org`
|
|
|
|
| 1 |
+
# ResearchBee v1.5.0 β Release A deployment guide
|
| 2 |
|
| 3 |
+
Release A is a correctness and safety gate. Journal and repository facts are now
|
| 4 |
+
constrained by server-side guards, permission and embargo decisions use explicit
|
| 5 |
+
three-valued states, and the Khazna reload endpoint is authenticated.
|
| 6 |
|
| 7 |
+
## 1. Files changed in this release
|
|
|
|
| 8 |
|
| 9 |
+
| File | Change |
|
| 10 |
+
|---|---|
|
| 11 |
+
| `app.py` | Journal-policy hard guard, authenticated Khazna reload, citation-chain proxy endpoint, repository-strategy hydration, and subject results with all field quartiles |
|
| 12 |
+
| `prompts.py` | ID-only repository schema and removal of journal-policy generation from Journal Finder |
|
| 13 |
+
| `repositories.py` | Strict ID hydration; only fit reason/confidence survive from the model |
|
| 14 |
+
| `oaworks.py` | Permission and embargo tri-state logic |
|
| 15 |
+
| `ResearchBee_LibGuides.html` | Safe cover-letter/related-work events, fixed citation-chain panels, and subject-field quartile display |
|
| 16 |
+
| `tests/` | Regression tests for every Release A requirement |
|
| 17 |
+
| `requirements-dev.txt` | Test-only dependencies |
|
| 18 |
+
| `.env.example` | Secret and optional environment-variable template |
|
| 19 |
|
| 20 |
+
The bundled journal database, SCImago CSV, and other backend modules are retained
|
| 21 |
+
from the supplied Hugging Face project.
|
| 22 |
|
| 23 |
+
## 2. Required Hugging Face secrets
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
Add these under **Space Settings β Variables and secrets**:
|
|
|
|
| 26 |
|
| 27 |
+
```text
|
| 28 |
+
OPENAI_API_KEY=<your OpenAI API key>
|
| 29 |
+
KHAZNA_RELOAD_TOKEN=<a long random secret shared only with the harvest caller>
|
| 30 |
+
```
|
| 31 |
|
| 32 |
+
Generate the reload token with a password manager or, for example:
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
| 36 |
+
```
|
|
|
|
| 37 |
|
| 38 |
+
Optional Khazna dataset settings:
|
|
|
|
| 39 |
|
| 40 |
+
```text
|
| 41 |
+
KHAZNA_DATASET=nikeshn/researchbee-data
|
| 42 |
+
HF_TOKEN=<token with access to the dataset when it is private>
|
| 43 |
+
```
|
| 44 |
|
| 45 |
+
Do not place real secrets in `.env.example`, the LibGuides page, GitHub Pages,
|
| 46 |
+
or browser JavaScript.
|
| 47 |
|
| 48 |
+
## 3. Authenticated Khazna reload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
+
`POST /api/admin/reload-khazna` now fails closed:
|
| 51 |
|
| 52 |
+
- `503` when `KHAZNA_RELOAD_TOKEN` is not configured on the Space
|
| 53 |
+
- `401` when the caller supplies no token
|
| 54 |
+
- `403` when the supplied token is wrong
|
| 55 |
+
- success only after constant-time token comparison
|
| 56 |
|
| 57 |
+
The harvest workflow should call the endpoint with either header form below.
|
| 58 |
+
The custom header is recommended:
|
| 59 |
|
| 60 |
```bash
|
| 61 |
+
curl --fail-with-body --request POST \
|
| 62 |
+
"https://YOUR-SPACE.hf.space/api/admin/reload-khazna" \
|
| 63 |
+
--header "X-ResearchBee-Reload-Token: ${KHAZNA_RELOAD_TOKEN}"
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
Bearer form is also supported:
|
| 67 |
|
| 68 |
+
```bash
|
| 69 |
+
curl --fail-with-body --request POST \
|
| 70 |
+
"https://YOUR-SPACE.hf.space/api/admin/reload-khazna" \
|
| 71 |
+
--header "Authorization: Bearer ${KHAZNA_RELOAD_TOKEN}"
|
| 72 |
```
|
| 73 |
|
| 74 |
+
Store the same token as a protected secret in the system that runs the harvest.
|
| 75 |
+
Never call this endpoint from the LibGuides frontend.
|
| 76 |
+
|
| 77 |
+
## 4. LibGuides deployment
|
|
|
|
| 78 |
|
| 79 |
+
Use `ResearchBee_LibGuides.html` as the replacement LibGuides code block. Its
|
| 80 |
+
API base currently points to:
|
| 81 |
|
| 82 |
+
```text
|
| 83 |
+
https://nikeshn-researchbee.hf.space
|
| 84 |
+
```
|
| 85 |
|
| 86 |
+
Change `RB_API_BASE` and the existing `_HF` constants only when deploying to a
|
| 87 |
+
different Space.
|
| 88 |
|
| 89 |
+
Release A frontend behaviour:
|
|
|
|
|
|
|
| 90 |
|
| 91 |
+
- Cover-letter payloads remain in JavaScript memory and are not embedded in
|
| 92 |
+
inline event attributes.
|
| 93 |
+
- Related-work titles, authors, links, and citation-chain actions are created
|
| 94 |
+
through DOM APIs and event listeners.
|
| 95 |
+
- Citation-chain requests pass through `/api/openalex-cited-by`.
|
| 96 |
+
- Subject search expands each journal's complete `quartiles_by_field` list.
|
| 97 |
+
- Journal Finder explicitly says that deposit rights require the DOI-based
|
| 98 |
+
License Checking workflow.
|
| 99 |
|
| 100 |
+
## 5. Verification before publishing
|
|
|
|
| 101 |
|
| 102 |
+
Install production and test dependencies, then run:
|
| 103 |
|
| 104 |
+
```bash
|
| 105 |
+
python -m pip install -r requirements-dev.txt
|
| 106 |
+
pytest -q
|
| 107 |
+
python -m compileall -q .
|
| 108 |
```
|
| 109 |
+
|
| 110 |
+
The delivered Release A build passes 18 regression tests. The suite covers:
|
| 111 |
+
|
| 112 |
+
1. Repository ID schema and rejection of model-authored repository facts
|
| 113 |
+
2. Allowed/denied/unknown permission states
|
| 114 |
+
3. Zero/stated/unknown embargo states
|
| 115 |
+
4. Journal Finder policy downgrading
|
| 116 |
+
5. Citation-chain panel preservation
|
| 117 |
+
6. Removal of unsafe cover-letter and related-work inline handlers
|
| 118 |
+
7. Khazna reload authentication
|
| 119 |
+
8. Subject-search field quartiles
|
| 120 |
+
|
| 121 |
+
After deployment, check:
|
| 122 |
+
|
| 123 |
+
```text
|
| 124 |
+
GET /api/health
|
| 125 |
```
|
| 126 |
|
| 127 |
+
Then exercise the three UI workflows with test inputs. External source responses
|
| 128 |
+
may vary; an unavailable source must be rendered as unavailable or unknown, not
|
| 129 |
+
as a permissive default.
|
| 130 |
|
| 131 |
+
## 6. Tri-state interpretation
|
| 132 |
|
| 133 |
+
ResearchBee now distinguishes:
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
+
```text
|
| 136 |
+
permission_status: allowed | denied | unknown
|
| 137 |
+
embargo_status: zero | stated | unknown
|
| 138 |
+
```
|
| 139 |
|
| 140 |
+
An absent embargo field is `unknown`, never zero. Immediate open deposit is
|
| 141 |
+
`Yes` only when permission is allowed and the trusted record explicitly states
|
| 142 |
+
zero embargo. When no verified permission record exists, ResearchBee says that
|
| 143 |
+
it could not determine the right rather than claiming the right is denied.
|
|
|
|
| 144 |
|
| 145 |
+
## 7. Repository response contract
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
+
The language model may return only:
|
| 148 |
|
| 149 |
+
```json
|
| 150 |
+
{
|
| 151 |
+
"id": "exact_registry_id",
|
| 152 |
+
"fit_reason": "dataset-specific explanation",
|
| 153 |
+
"fit_confidence": "High|Medium|Low"
|
| 154 |
+
}
|
| 155 |
+
```
|
| 156 |
|
| 157 |
+
The server rejects unknown IDs and supplies the repository name, URL, cost,
|
| 158 |
+
identifier, versioning, embargo support, source URL, and verification date from
|
| 159 |
+
`repositories.py`.
|
|
|
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: π
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: purple
|
|
@@ -7,96 +7,110 @@ sdk: docker
|
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
-
short_description: Journal finder, OA rights checker, data repository guide
|
| 11 |
---
|
| 12 |
|
| 13 |
-
# π
|
| 14 |
|
| 15 |
-
**Publishing
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
## β¨ Features
|
| 24 |
|
| 25 |
-
|
| 26 |
-
-
|
| 27 |
-
-
|
| 28 |
-
-
|
| 29 |
-
-
|
| 30 |
-
|
| 31 |
|
| 32 |
-
###
|
| 33 |
-
- Version-specific self-archiving policy (3-version breakdown)
|
| 34 |
-
- Embargo lengths, licence conditions, risk flags
|
| 35 |
-
- Direct deposit guidance for Khazna
|
| 36 |
|
| 37 |
-
|
| 38 |
-
-
|
| 39 |
-
-
|
| 40 |
-
-
|
| 41 |
-
-
|
|
|
|
| 42 |
|
| 43 |
-
###
|
| 44 |
-
- KU institutional repository always recommended across all three tabs
|
| 45 |
-
- Article and dataset deposit guidance
|
| 46 |
-
- Contact: khazna@ku.ac.ae
|
| 47 |
|
| 48 |
-
-
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
##
|
| 51 |
|
| 52 |
-
```
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
| 60 |
```
|
| 61 |
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
|
| 67 |
-
1. Add secret: `OPENAI_API_KEY` in Space Settings β Secrets
|
| 68 |
-
2. All files are in the Space root and `static/` folder
|
| 69 |
-
3. Ping `/api/health` every 5 min via UptimeRobot to prevent cold starts
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
| 74 |
|
| 75 |
-
-
|
|
|
|
| 76 |
|
| 77 |
-
##
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
| OpenAI API | All AI analysis | Yes |
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
| 88 |
|
| 89 |
-
|
| 90 |
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
-
|
|
|
|
| 94 |
|
| 95 |
-
|
| 96 |
-
- ποΈ Khazna: khazna@ku.ac.ae
|
| 97 |
-
- π [khazna.ku.ac.ae](https://khazna.ku.ac.ae)
|
| 98 |
-
- π [library.ku.ac.ae/lib](https://library.ku.ac.ae/lib)
|
| 99 |
|
| 100 |
-
-
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
MIT License β Khalifa University Library
|
|
|
|
| 1 |
---
|
| 2 |
+
title: ResearchBee
|
| 3 |
emoji: π
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: purple
|
|
|
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
+
short_description: Journal finder, OA rights checker, and data repository guide
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# π ResearchBee v1.5.0
|
| 14 |
|
| 15 |
+
**Publishing, repository, and Open Access assistant for Khalifa University researchers**
|
| 16 |
|
| 17 |
+
ResearchBee helps researchers identify suitable journals, check article-level
|
| 18 |
+
self-archiving rights, and select an appropriate research-data repository.
|
| 19 |
+
Verified databases determine factual values; the language model interprets the
|
| 20 |
+
researcher's needs and explains server-supplied results.
|
| 21 |
|
| 22 |
+
## Main workflows
|
| 23 |
|
| 24 |
+
### Journal Finder
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
- Manuscript-fit analysis with 10 detailed recommendations and up to 20 quick matches
|
| 27 |
+
- Subject browsing against a closed SCImago/Scopus classification vocabulary
|
| 28 |
+
- Journal metrics enriched from the bundled journal index and OpenAlex
|
| 29 |
+
- Complete quartile-by-subject-field information in manuscript and subject results
|
| 30 |
+
- No model-generated self-archiving facts: DOI-based License Checking is required
|
| 31 |
+
before any manuscript version is deposited
|
| 32 |
|
| 33 |
+
### License Checking
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
- DOI-first article-level permissions from ShareYourPaper Permissions/OA.Works
|
| 36 |
+
- Explicit permission states: `allowed`, `denied`, or `unknown`
|
| 37 |
+
- Explicit embargo states: `zero`, `stated`, or `unknown`
|
| 38 |
+
- Khazna holdings status kept separate from policy status
|
| 39 |
+
- Missing permission or embargo information remains unknown rather than becoming
|
| 40 |
+
a permissive or prohibitive default
|
| 41 |
|
| 42 |
+
### Data Repository Finder
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
+
- Model selects exact IDs from a curated KU Library registry
|
| 45 |
+
- Server hydrates all factual repository attributes
|
| 46 |
+
- Unknown IDs and model-authored facts are discarded
|
| 47 |
+
- Khazna is included as the KU institutional repository
|
| 48 |
|
| 49 |
+
## Architecture
|
| 50 |
|
| 51 |
+
```text
|
| 52 |
+
LibGuides frontend
|
| 53 |
+
β HTTPS
|
| 54 |
+
βΌ
|
| 55 |
+
Hugging Face Space β FastAPI
|
| 56 |
+
βββ journals.db / SCImago journal data
|
| 57 |
+
βββ OpenAlex metadata and citation services
|
| 58 |
+
βββ OA.Works article permissions
|
| 59 |
+
βββ Khazna index
|
| 60 |
+
βββ Curated repository registry
|
| 61 |
+
βββ OpenAI for interpretation, ranking, and explanations
|
| 62 |
```
|
| 63 |
|
| 64 |
+
## Deployment
|
| 65 |
+
|
| 66 |
+
Required Space secrets:
|
| 67 |
|
| 68 |
+
```text
|
| 69 |
+
OPENAI_API_KEY
|
| 70 |
+
KHAZNA_RELOAD_TOKEN
|
| 71 |
+
```
|
| 72 |
|
| 73 |
+
Optional Khazna dataset settings:
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
+
```text
|
| 76 |
+
KHAZNA_DATASET
|
| 77 |
+
HF_TOKEN
|
| 78 |
+
```
|
| 79 |
|
| 80 |
+
See `README-DEPLOY.md` for the authenticated reload call, LibGuides replacement,
|
| 81 |
+
and verification procedure.
|
| 82 |
|
| 83 |
+
## Tests
|
| 84 |
|
| 85 |
+
```bash
|
| 86 |
+
python -m pip install -r requirements-dev.txt
|
| 87 |
+
pytest -q
|
| 88 |
+
```
|
|
|
|
| 89 |
|
| 90 |
+
Release A includes regression coverage for repository ID grounding, permission
|
| 91 |
+
and embargo tri-state logic, journal-policy downgrading, citation-chain DOM
|
| 92 |
+
preservation, safe event handling, Khazna reload authentication, and subject
|
| 93 |
+
quartiles by field.
|
| 94 |
|
| 95 |
+
## Data sources
|
| 96 |
|
| 97 |
+
| Source | Purpose |
|
| 98 |
+
|---|---|
|
| 99 |
+
| Bundled journal index / SCImago data | Journal rankings, lifecycle, and subject quartiles |
|
| 100 |
+
| OpenAlex | Journal and works metadata, citation discovery |
|
| 101 |
+
| ShareYourPaper Permissions / OA.Works | Article-level self-archiving permissions |
|
| 102 |
+
| Khazna | KU repository holdings status |
|
| 103 |
+
| Curated KU Library registry | Research-data repository facts |
|
| 104 |
+
| OpenAI | Manuscript understanding, closed-list selection, ranking explanations, and cover letters |
|
| 105 |
|
| 106 |
+
Always verify time-sensitive publisher policies and repository terms at the
|
| 107 |
+
linked primary sources before submission or deposit.
|
| 108 |
|
| 109 |
+
## Contacts
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
+
- KU Library: `library@ku.ac.ae`
|
| 112 |
+
- Khazna: `khazna@ku.ac.ae`
|
| 113 |
+
- Khazna portal: <https://khazna.ku.ac.ae>
|
| 114 |
+
- KU Library: <https://library.ku.ac.ae/lib>
|
| 115 |
|
| 116 |
MIT License β Khalifa University Library
|
RELEASE_A_NOTES.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ResearchBee Release A β correctness and safety gate
|
| 2 |
+
|
| 3 |
+
Version: 1.5.0
|
| 4 |
+
|
| 5 |
+
## Implemented requirements
|
| 6 |
+
|
| 7 |
+
1. **Repository ID schema** β the prompt returns exact registry IDs; the server
|
| 8 |
+
retains only fit reason/confidence and hydrates every factual field.
|
| 9 |
+
2. **Permission and embargo tri-state** β allowed/denied/unknown and
|
| 10 |
+
zero/stated/unknown are propagated through policy parsing, recommendations,
|
| 11 |
+
actions, and the frontend.
|
| 12 |
+
3. **Journal-policy downgrade** β Journal Finder model output is overwritten by
|
| 13 |
+
a server-side not-checked response; unsafe policy-bearing next actions are
|
| 14 |
+
discarded.
|
| 15 |
+
4. **Citation-chain preservation** β related works load only into the cited
|
| 16 |
+
results panel, leaving tabs and the citation-chain panel in the DOM.
|
| 17 |
+
5. **Safe cover-letter and related-work events** β raw values are stored in Maps;
|
| 18 |
+
rendering uses `textContent`, allowlisted HTTPS URLs, and `addEventListener`.
|
| 19 |
+
6. **Khazna reload authentication** β the endpoint requires a shared secret via
|
| 20 |
+
`X-ResearchBee-Reload-Token` or Bearer authorization and fails closed when the
|
| 21 |
+
secret is not configured.
|
| 22 |
+
7. **Regression tests** β 18 tests cover all Release A requirements.
|
| 23 |
+
8. **Subject quartiles by field** β subject browse results now retrieve and
|
| 24 |
+
display every `quartiles_by_field` entry for each journal.
|
| 25 |
+
|
| 26 |
+
## Changed backend files
|
| 27 |
+
|
| 28 |
+
- `app.py`
|
| 29 |
+
- `prompts.py`
|
| 30 |
+
- `repositories.py`
|
| 31 |
+
- `oaworks.py`
|
| 32 |
+
- `README.md`
|
| 33 |
+
- `README-DEPLOY.md`
|
| 34 |
+
- `.env.example`
|
| 35 |
+
- `requirements-dev.txt`
|
| 36 |
+
- `tests/`
|
| 37 |
+
|
| 38 |
+
## Frontend file
|
| 39 |
+
|
| 40 |
+
- `ResearchBee_LibGuides.html`
|
| 41 |
+
|
| 42 |
+
## Required deployment action
|
| 43 |
+
|
| 44 |
+
Create a long random `KHAZNA_RELOAD_TOKEN` in the Hugging Face Space and store
|
| 45 |
+
the same value in the Khazna harvest caller. The reload endpoint is unavailable
|
| 46 |
+
until this secret is configured.
|
ResearchBee_LibGuides.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
admin.py
CHANGED
|
@@ -1,731 +1,731 @@
|
|
| 1 |
-
"""
|
| 2 |
-
admin.py β ResearchBee admin dashboard
|
| 3 |
-
|
| 4 |
-
Ported from LibBee v3.8's admin module so the two dashboards behave
|
| 5 |
-
identically: same cookie-session login, same 5-attempt IP lockout, same
|
| 6 |
-
server-rendered single page, same visual language. Someone who
|
| 7 |
-
administers LibBee should not have to learn a second interface.
|
| 8 |
-
|
| 9 |
-
What is deliberately different
|
| 10 |
-
------------------------------
|
| 11 |
-
LibBee's dashboard shows recent questions. This one cannot, because
|
| 12 |
-
ResearchBee never stores question text β see usage.py. The equivalent
|
| 13 |
-
panels here answer "how much of each tool is being used" and "how often
|
| 14 |
-
could we not answer", both from counters.
|
| 15 |
-
|
| 16 |
-
Mount with:
|
| 17 |
-
from admin import router as admin_router
|
| 18 |
-
app.include_router(admin_router, prefix="/admin")
|
| 19 |
-
|
| 20 |
-
Environment:
|
| 21 |
-
RESEARCHBEE_ADMIN_PASSWORD required β the dashboard is unreachable without it
|
| 22 |
-
CLOUDFLARE_WORKER_URL the LibBee Worker (shared)
|
| 23 |
-
CLOUDFLARE_WORKER_TOKEN must equal the Worker's ANALYTICS_TOKEN
|
| 24 |
-
"""
|
| 25 |
-
|
| 26 |
-
from __future__ import annotations
|
| 27 |
-
|
| 28 |
-
import json
|
| 29 |
-
import os
|
| 30 |
-
import secrets
|
| 31 |
-
import time
|
| 32 |
-
from typing import Any, Dict, Optional
|
| 33 |
-
|
| 34 |
-
from fastapi import APIRouter, HTTPException, Request, Response
|
| 35 |
-
from fastapi.responses import HTMLResponse, RedirectResponse
|
| 36 |
-
from pydantic import BaseModel
|
| 37 |
-
|
| 38 |
-
router = APIRouter()
|
| 39 |
-
|
| 40 |
-
_START_TIME = time.time()
|
| 41 |
-
|
| 42 |
-
ADMIN_PASSWORD = (os.environ.get("RESEARCHBEE_ADMIN_PASSWORD") or "").strip()
|
| 43 |
-
WORKER_URL = (os.environ.get("CLOUDFLARE_WORKER_URL") or "").strip().strip('"').strip("'").rstrip("/")
|
| 44 |
-
WORKER_TOKEN = (os.environ.get("CLOUDFLARE_WORKER_TOKEN") or "").strip()
|
| 45 |
-
|
| 46 |
-
_SESSIONS_FILE = "/tmp/researchbee_admin_sessions.json"
|
| 47 |
-
|
| 48 |
-
admin_sessions: Dict[str, float] = {}
|
| 49 |
-
|
| 50 |
-
# Failed logins per IP. Five misses inside the window locks that IP out
|
| 51 |
-
# for fifteen minutes β enough to make guessing a shared password
|
| 52 |
-
# impractical without locking out a librarian who fat-fingers it twice.
|
| 53 |
-
_login_attempts: Dict[str, list] = {}
|
| 54 |
-
_MAX_LOGIN_ATTEMPTS = 5
|
| 55 |
-
_LOCKOUT_WINDOW = 300
|
| 56 |
-
_LOCKOUT_DURATION = 900
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
# ββ Sessions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 60 |
-
|
| 61 |
-
def _load_sessions() -> None:
|
| 62 |
-
try:
|
| 63 |
-
with open(_SESSIONS_FILE, "r", encoding="utf-8") as f:
|
| 64 |
-
data = json.load(f)
|
| 65 |
-
now = time.time()
|
| 66 |
-
admin_sessions.update({
|
| 67 |
-
t: exp for t, exp in data.items()
|
| 68 |
-
if isinstance(exp, (int, float)) and exp > now
|
| 69 |
-
})
|
| 70 |
-
except Exception:
|
| 71 |
-
pass
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def _save_sessions() -> None:
|
| 75 |
-
try:
|
| 76 |
-
now = time.time()
|
| 77 |
-
with open(_SESSIONS_FILE, "w", encoding="utf-8") as f:
|
| 78 |
-
json.dump({t: e for t, e in admin_sessions.items() if e > now}, f)
|
| 79 |
-
except Exception:
|
| 80 |
-
pass
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def _cleanup_sessions() -> None:
|
| 84 |
-
now = time.time()
|
| 85 |
-
for t in [t for t, exp in admin_sessions.items() if exp <= now]:
|
| 86 |
-
admin_sessions.pop(t, None)
|
| 87 |
-
_load_sessions()
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _has_session(request: Request) -> bool:
|
| 91 |
-
_cleanup_sessions()
|
| 92 |
-
token = request.cookies.get("rb_admin_session")
|
| 93 |
-
return bool(token and token in admin_sessions)
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def _require_session(request: Request) -> None:
|
| 97 |
-
if not _has_session(request):
|
| 98 |
-
raise HTTPException(status_code=401, detail="Not authenticated")
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
class LoginRequest(BaseModel):
|
| 102 |
-
password: str
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
# ββ Auth ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 106 |
-
|
| 107 |
-
@router.post("/auth")
|
| 108 |
-
async def admin_auth(req: LoginRequest, request: Request):
|
| 109 |
-
client_ip = request.client.host if request.client else "unknown"
|
| 110 |
-
now = time.time()
|
| 111 |
-
|
| 112 |
-
attempts = [t for t in _login_attempts.get(client_ip, []) if now - t < _LOCKOUT_WINDOW]
|
| 113 |
-
if len(attempts) >= _MAX_LOGIN_ATTEMPTS:
|
| 114 |
-
wait = int(_LOCKOUT_DURATION - (now - attempts[0]))
|
| 115 |
-
raise HTTPException(429, f"Too many failed attempts. Try again in {wait} seconds.")
|
| 116 |
-
|
| 117 |
-
if not ADMIN_PASSWORD or not secrets.compare_digest(
|
| 118 |
-
req.password.encode(), ADMIN_PASSWORD.encode()
|
| 119 |
-
):
|
| 120 |
-
attempts.append(now)
|
| 121 |
-
_login_attempts[client_ip] = attempts
|
| 122 |
-
remaining = _MAX_LOGIN_ATTEMPTS - len(attempts)
|
| 123 |
-
if not ADMIN_PASSWORD:
|
| 124 |
-
detail = "No admin password is set on this Space. Add RESEARCHBEE_ADMIN_PASSWORD to the Space secrets."
|
| 125 |
-
elif remaining <= 0:
|
| 126 |
-
detail = f"Too many failed attempts. Try again in {_LOCKOUT_DURATION // 60} minutes."
|
| 127 |
-
else:
|
| 128 |
-
detail = f"Incorrect password. {remaining} attempt{'s' if remaining != 1 else ''} left before lockout."
|
| 129 |
-
raise HTTPException(401, detail)
|
| 130 |
-
|
| 131 |
-
_login_attempts.pop(client_ip, None)
|
| 132 |
-
token = secrets.token_hex(32)
|
| 133 |
-
admin_sessions[token] = now + 86400
|
| 134 |
-
_save_sessions()
|
| 135 |
-
resp = Response(content='{"status":"ok"}', media_type="application/json")
|
| 136 |
-
resp.set_cookie("rb_admin_session", token, httponly=True,
|
| 137 |
-
max_age=86400, samesite="lax", secure=True)
|
| 138 |
-
return resp
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
@router.get("/logout")
|
| 142 |
-
async def admin_logout(request: Request):
|
| 143 |
-
token = request.cookies.get("rb_admin_session")
|
| 144 |
-
if token:
|
| 145 |
-
admin_sessions.pop(token, None)
|
| 146 |
-
_save_sessions()
|
| 147 |
-
resp = RedirectResponse(url="/admin/login")
|
| 148 |
-
resp.delete_cookie("rb_admin_session")
|
| 149 |
-
return resp
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
# ββ Worker proxies ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 153 |
-
# The Worker URL and token stay server-side. The browser only ever talks
|
| 154 |
-
# to this Space, so neither secret reaches the page.
|
| 155 |
-
|
| 156 |
-
async def _worker_get(path: str, params: Optional[dict] = None):
|
| 157 |
-
if not WORKER_URL:
|
| 158 |
-
raise HTTPException(503, "CLOUDFLARE_WORKER_URL is not set on this Space")
|
| 159 |
-
import httpx
|
| 160 |
-
headers = {"Authorization": f"Bearer {WORKER_TOKEN}"} if WORKER_TOKEN else {}
|
| 161 |
-
try:
|
| 162 |
-
async with httpx.AsyncClient(timeout=15) as client:
|
| 163 |
-
r = await client.get(f"{WORKER_URL}{path}", params=params or {}, headers=headers)
|
| 164 |
-
return r.json()
|
| 165 |
-
except HTTPException:
|
| 166 |
-
raise
|
| 167 |
-
except Exception as exc:
|
| 168 |
-
raise HTTPException(502, f"Worker unreachable: {type(exc).__name__}: {exc}")
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
@router.get("/usage")
|
| 172 |
-
async def admin_usage(request: Request, days: int = 30):
|
| 173 |
-
_require_session(request)
|
| 174 |
-
return await _worker_get("/rb/usage", {"days": max(0, min(days, 3650))})
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
@router.get("/canary-status")
|
| 178 |
-
async def admin_canary_status(request: Request):
|
| 179 |
-
_require_session(request)
|
| 180 |
-
return await _worker_get("/rb/canary/status")
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
@router.get("/local-usage")
|
| 184 |
-
async def admin_local_usage(request: Request):
|
| 185 |
-
"""Counts held in this container since it last started. Useful when
|
| 186 |
-
the Worker is unreachable, and as a cross-check that counting is
|
| 187 |
-
actually happening before blaming the Worker."""
|
| 188 |
-
_require_session(request)
|
| 189 |
-
from usage import usage
|
| 190 |
-
return usage.snapshot()
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
# ββ Pull endpoints (called by the Cloudflare Worker, not by a browser) β
|
| 194 |
-
# Authenticated with the shared token rather than an admin session,
|
| 195 |
-
# because the caller is a machine. This is the direction that works:
|
| 196 |
-
# Cloudflare can reach this Space even though this Space cannot reach
|
| 197 |
-
# Cloudflare.
|
| 198 |
-
|
| 199 |
-
def _require_worker_token(request: Request) -> None:
|
| 200 |
-
if not WORKER_TOKEN:
|
| 201 |
-
raise HTTPException(503, "CLOUDFLARE_WORKER_TOKEN is not set on this Space")
|
| 202 |
-
supplied = request.headers.get("authorization", "")
|
| 203 |
-
if not secrets.compare_digest(supplied, f"Bearer {WORKER_TOKEN}"):
|
| 204 |
-
raise HTTPException(401, "Unauthorized")
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
def _worker_or_session(request: Request) -> None:
|
| 208 |
-
"""Accept either the Worker's token or a signed-in admin, so the same
|
| 209 |
-
endpoints back both the cron pull and the dashboard's manual send."""
|
| 210 |
-
if _has_session(request):
|
| 211 |
-
return
|
| 212 |
-
_require_worker_token(request)
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
@router.get("/usage-export")
|
| 216 |
-
async def usage_export(request: Request):
|
| 217 |
-
"""Hand over accumulated counts. Counts are held, not deleted, until
|
| 218 |
-
the caller confirms with /usage-ack β so a caller that reads them and
|
| 219 |
-
then fails to store them cannot destroy them."""
|
| 220 |
-
_worker_or_session(request)
|
| 221 |
-
from usage import usage
|
| 222 |
-
return usage.export_snapshot()
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
@router.post("/usage-ack")
|
| 226 |
-
async def usage_ack(request: Request, snapshot_id: str):
|
| 227 |
-
"""Confirm a snapshot reached storage. Only now is it discarded."""
|
| 228 |
-
_worker_or_session(request)
|
| 229 |
-
from usage import usage
|
| 230 |
-
return {"acknowledged": usage.ack_snapshot(snapshot_id)}
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
@router.get("/worker-probe")
|
| 234 |
-
async def admin_worker_probe(request: Request):
|
| 235 |
-
"""Is this Space's route to the Worker broken, or just slow?
|
| 236 |
-
|
| 237 |
-
HF Spaces lose connectivity to workers.dev for stretches at a time β
|
| 238 |
-
LibBee hit the same thing and moved its analytics writes into the
|
| 239 |
-
browser because of it. Three attempts with timings tell you which
|
| 240 |
-
case you are in: three identical failures is a blocked route, a mix
|
| 241 |
-
is the intermittent case, which the flush buffer already absorbs.
|
| 242 |
-
"""
|
| 243 |
-
_require_session(request)
|
| 244 |
-
import httpx, time as _t
|
| 245 |
-
if not WORKER_URL:
|
| 246 |
-
return {"error": "CLOUDFLARE_WORKER_URL is not set on this Space"}
|
| 247 |
-
attempts = []
|
| 248 |
-
for i in range(3):
|
| 249 |
-
t0 = _t.time()
|
| 250 |
-
try:
|
| 251 |
-
async with httpx.AsyncClient(timeout=20) as client:
|
| 252 |
-
r = await client.get(f"{WORKER_URL}/version")
|
| 253 |
-
attempts.append({"attempt": i + 1, "ok": r.status_code == 200,
|
| 254 |
-
"status": r.status_code,
|
| 255 |
-
"ms": int((_t.time() - t0) * 1000)})
|
| 256 |
-
except Exception as exc:
|
| 257 |
-
attempts.append({"attempt": i + 1, "ok": False,
|
| 258 |
-
"error": f"{type(exc).__name__}: {exc}"[:160],
|
| 259 |
-
"ms": int((_t.time() - t0) * 1000)})
|
| 260 |
-
ok = sum(1 for a in attempts if a.get("ok"))
|
| 261 |
-
return {
|
| 262 |
-
"worker_url": WORKER_URL,
|
| 263 |
-
"attempts": attempts,
|
| 264 |
-
"verdict": ("reachable" if ok == 3 else
|
| 265 |
-
"intermittent" if ok else "unreachable from this Space"),
|
| 266 |
-
"note": ("Counts are buffered and retried every 60 seconds, so patchy "
|
| 267 |
-
"egress delays the dashboard without losing data. Dashboard "
|
| 268 |
-
"reads fall back to your browser, which usually reaches the "
|
| 269 |
-
"Worker even when this Space cannot."),
|
| 270 |
-
}
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
@router.get("/status")
|
| 274 |
-
async def admin_status(request: Request):
|
| 275 |
-
_require_session(request)
|
| 276 |
-
import app as rb_app
|
| 277 |
-
|
| 278 |
-
out: Dict[str, Any] = {
|
| 279 |
-
"uptime_seconds": int(time.time() - _START_TIME),
|
| 280 |
-
"worker_url_configured": bool(WORKER_URL),
|
| 281 |
-
"worker_token_configured": bool(WORKER_TOKEN),
|
| 282 |
-
"admin_password_set": bool(ADMIN_PASSWORD),
|
| 283 |
-
"openai_key_set": bool(os.environ.get("OPENAI_API_KEY")),
|
| 284 |
-
}
|
| 285 |
-
try:
|
| 286 |
-
out["health"] = await rb_app.health()
|
| 287 |
-
except Exception as exc:
|
| 288 |
-
out["health"] = {"error": f"{type(exc).__name__}: {exc}"}
|
| 289 |
-
return out
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
@router.post("/reload-khazna")
|
| 293 |
-
async def admin_reload_khazna(request: Request):
|
| 294 |
-
"""Session-protected wrapper around the existing reload endpoint, so
|
| 295 |
-
the button on this page works without exposing an unauthenticated
|
| 296 |
-
admin path."""
|
| 297 |
-
_require_session(request)
|
| 298 |
-
import asyncio
|
| 299 |
-
from app import khazna_index
|
| 300 |
-
ok = await asyncio.to_thread(khazna_index.reload)
|
| 301 |
-
return {"reloaded": ok, "khazna": khazna_index.status}
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
# ββ Login page ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 305 |
-
|
| 306 |
-
@router.get("/login", response_class=HTMLResponse)
|
| 307 |
-
async def admin_login_page(request: Request):
|
| 308 |
-
if _has_session(request):
|
| 309 |
-
return RedirectResponse(url="/admin")
|
| 310 |
-
return HTMLResponse("""<!DOCTYPE html>
|
| 311 |
-
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 312 |
-
<title>ResearchBee β Admin</title>
|
| 313 |
-
<style>
|
| 314 |
-
*{box-sizing:border-box;margin:0;padding:0}
|
| 315 |
-
body{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;display:flex;justify-content:center;align-items:center;min-height:100vh}
|
| 316 |
-
.box{background:#fff;border-radius:16px;padding:40px;width:360px;box-shadow:0 4px 24px rgba(0,0,0,.1);text-align:center}
|
| 317 |
-
h1{color:#003366;font-size:1.3rem;margin-bottom:6px}
|
| 318 |
-
.sub{color:#6b7280;font-size:.84rem;margin-bottom:24px}
|
| 319 |
-
input{width:100%;padding:12px 14px;border:1.5px solid #d1d5db;border-radius:10px;font-size:.92rem;margin-bottom:14px}
|
| 320 |
-
input:focus{outline:none;border-color:#003366}
|
| 321 |
-
.btn{width:100%;padding:12px;background:#003366;color:#fff;border:none;border-radius:10px;font-weight:700;font-size:.92rem;cursor:pointer}
|
| 322 |
-
.btn:hover{background:#004488}
|
| 323 |
-
.err{color:#dc2626;font-size:.82rem;margin-bottom:10px;display:none}
|
| 324 |
-
</style></head><body>
|
| 325 |
-
<div class="box">
|
| 326 |
-
<h1>π ResearchBee Admin</h1>
|
| 327 |
-
<div class="sub">KU Library publishing assistant</div>
|
| 328 |
-
<div class="err" id="err"></div>
|
| 329 |
-
<form onsubmit="return go(event)">
|
| 330 |
-
<input type="password" id="pw" placeholder="Admin password" autofocus>
|
| 331 |
-
<button type="submit" class="btn">Sign in</button>
|
| 332 |
-
</form>
|
| 333 |
-
</div>
|
| 334 |
-
<script>
|
| 335 |
-
async function go(e){
|
| 336 |
-
e.preventDefault();
|
| 337 |
-
const r=await fetch('/admin/auth',{method:'POST',headers:{'Content-Type':'application/json'},
|
| 338 |
-
body:JSON.stringify({password:document.getElementById('pw').value})});
|
| 339 |
-
if(r.ok){window.location.href='/admin';return false;}
|
| 340 |
-
const d=await r.json().catch(()=>({}));
|
| 341 |
-
const el=document.getElementById('err');
|
| 342 |
-
el.textContent=d.detail||'Incorrect password.';el.style.display='block';
|
| 343 |
-
document.getElementById('pw').value='';document.getElementById('pw').focus();
|
| 344 |
-
return false;
|
| 345 |
-
}
|
| 346 |
-
</script></body></html>""")
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
# ββ Dashboard βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 350 |
-
|
| 351 |
-
SURFACE_LABELS = {
|
| 352 |
-
"journal_subject": "Find a journal β browse by subject",
|
| 353 |
-
"journal_manuscript": "Find a journal β analyse manuscript",
|
| 354 |
-
"licence_deposit": "Share your article (deposit rights)",
|
| 355 |
-
"data_repository": "Deposit your data (repository finder)",
|
| 356 |
-
"cover_letter": "Cover letter generator",
|
| 357 |
-
"chat": "Chat assistant",
|
| 358 |
-
}
|
| 359 |
-
|
| 360 |
-
GAP_LABELS = {
|
| 361 |
-
"scimago_miss": "Subject not found in the journal index",
|
| 362 |
-
"subject_unrecognised": "Subject did not map to any SCImago category",
|
| 363 |
-
# Model named a category that does not exist. High counts here mean the
|
| 364 |
-
# vocabulary is missing terms researchers actually use (e.g. criminology).
|
| 365 |
-
"category_invented": "Subject has no category in Scopus/SCImago",
|
| 366 |
-
# Answered, but with the wrong kind of answer: no category matched, so the
|
| 367 |
-
# list is the whole subject AREA ranked by h-index. A high count here means
|
| 368 |
-
# the vocabulary is missing terms researchers actually use.
|
| 369 |
-
"area_fallback": "Showed a broad subject area, not a specific category",
|
| 370 |
-
"oaworks_404": "No permissions record for that article",
|
| 371 |
-
"oaworks_unconfirmed": "Permissions record found but policy not confirmed",
|
| 372 |
-
"doi_unparseable": "Could not read a DOI from what was entered",
|
| 373 |
-
"khazna_no_doi": "No DOI supplied, so Khazna could not be checked",
|
| 374 |
-
"khazna_unavailable": "Khazna index not loaded",
|
| 375 |
-
"no_apc_agreement": "No KU APC agreement for that publisher",
|
| 376 |
-
"no_repository_match": "No repository matched the dataset",
|
| 377 |
-
}
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
@router.get("", response_class=HTMLResponse)
|
| 381 |
-
async def admin_dashboard(request: Request):
|
| 382 |
-
if not _has_session(request):
|
| 383 |
-
return RedirectResponse(url="/admin/login")
|
| 384 |
-
|
| 385 |
-
page = """<!DOCTYPE html>
|
| 386 |
-
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 387 |
-
<title>ResearchBee β Admin</title>
|
| 388 |
-
<style>
|
| 389 |
-
*{box-sizing:border-box;margin:0;padding:0}
|
| 390 |
-
body{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;color:#1a1a2e}
|
| 391 |
-
.topbar{background:#003366;color:#fff;padding:12px 20px;display:flex;align-items:center;justify-content:space-between}
|
| 392 |
-
.topbar h1{font-size:1.1rem;font-weight:700}
|
| 393 |
-
.topbar a{color:#C8A951;font-size:.82rem;text-decoration:none;padding:5px 12px;border:1px solid #C8A951;border-radius:8px}
|
| 394 |
-
.main{max-width:900px;margin:24px auto;padding:0 16px;display:flex;flex-direction:column;gap:20px}
|
| 395 |
-
.card{background:#fff;border-radius:14px;padding:20px;box-shadow:0 2px 12px rgba(0,0,0,.07)}
|
| 396 |
-
.card h2{font-size:1rem;font-weight:700;color:#003366;margin-bottom:6px}
|
| 397 |
-
.card .hint{font-size:.78rem;color:#6b7280;margin-bottom:14px;line-height:1.5}
|
| 398 |
-
.btn{padding:9px 20px;background:#003366;color:#fff;border:none;border-radius:8px;font-weight:700;cursor:pointer;font-size:.88rem}
|
| 399 |
-
.btn:hover{background:#004488}
|
| 400 |
-
.btn-ghost{background:#fff;color:#003366;border:1.5px solid #d1d5db}
|
| 401 |
-
.btn-ghost:hover{background:#f8fafc}
|
| 402 |
-
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px}
|
| 403 |
-
.stat{background:#f8fafc;border-radius:10px;padding:14px;text-align:center;border:1px solid #e5e7eb}
|
| 404 |
-
.stat .val{font-size:1.6rem;font-weight:800;color:#003366}
|
| 405 |
-
.stat .lbl{font-size:.74rem;color:#6b7280;margin-top:2px}
|
| 406 |
-
table{width:100%;border-collapse:collapse;font-size:.84rem}
|
| 407 |
-
th{text-align:left;font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;border-bottom:1px solid #e5e7eb;padding:7px 8px}
|
| 408 |
-
td{padding:7px 8px;border-bottom:1px solid #f1f5f9}
|
| 409 |
-
tr:last-child td{border-bottom:none}
|
| 410 |
-
.bar{height:8px;background:#e5e7eb;border-radius:4px;overflow:hidden;min-width:60px}
|
| 411 |
-
.bar span{display:block;height:100%;background:#003366}
|
| 412 |
-
.pill{display:inline-block;padding:2px 9px;border-radius:20px;font-size:.72rem;font-weight:700}
|
| 413 |
-
.pill-ok{background:#dcfce7;color:#15803d}
|
| 414 |
-
.pill-bad{background:#fee2e2;color:#b91c1c}
|
| 415 |
-
.pill-warn{background:#fef3c7;color:#92400e}
|
| 416 |
-
.range{display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap}
|
| 417 |
-
.range button{padding:4px 12px;border:1.5px solid #d1d5db;background:#fff;border-radius:20px;font-size:.78rem;cursor:pointer;color:#6b7280}
|
| 418 |
-
.range button.on{border-color:#003366;background:#003366;color:#fff;font-weight:700}
|
| 419 |
-
.msg{font-size:.82rem;color:#6b7280;padding:8px 0}
|
| 420 |
-
.err{font-size:.82rem;color:#b91c1c;padding:8px 0}
|
| 421 |
-
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.76rem}
|
| 422 |
-
</style></head><body>
|
| 423 |
-
<div class="topbar">
|
| 424 |
-
<h1>π ResearchBee β Admin</h1>
|
| 425 |
-
<a href="/admin/logout">Sign out</a>
|
| 426 |
-
</div>
|
| 427 |
-
<div class="main">
|
| 428 |
-
|
| 429 |
-
<div class="card">
|
| 430 |
-
<h2>Tool usage</h2>
|
| 431 |
-
<div class="hint">How many times each tool was used. Counts only β ResearchBee never
|
| 432 |
-
stores what anyone typed, so there is no question log here by design.
|
| 433 |
-
Keep-alive traffic is excluded.</div>
|
| 434 |
-
<div class="range">
|
| 435 |
-
<button data-days="7">7 days</button>
|
| 436 |
-
<button data-days="30" class="on">30 days</button>
|
| 437 |
-
<button data-days="90">90 days</button>
|
| 438 |
-
<button data-days="0">All time</button>
|
| 439 |
-
</div>
|
| 440 |
-
<div id="usage"><div class="msg">Loadingβ¦</div></div>
|
| 441 |
-
</div>
|
| 442 |
-
|
| 443 |
-
<div class="card">
|
| 444 |
-
<h2>Questions we could not answer</h2>
|
| 445 |
-
<div class="hint">Requests that completed but found nothing to answer with β a subject
|
| 446 |
-
missing from the journal index, an article with no permissions record. Each one is a
|
| 447 |
-
candidate for improving a data source.</div>
|
| 448 |
-
<div id="gaps"><div class="msg">Loadingβ¦</div></div>
|
| 449 |
-
</div>
|
| 450 |
-
|
| 451 |
-
<div class="card">
|
| 452 |
-
<h2>Health checks</h2>
|
| 453 |
-
<div class="hint">Every six hours the Worker asks ResearchBee a real question, rotating
|
| 454 |
-
through the four tools. This keeps the Space awake and proves a different pipeline
|
| 455 |
-
still works each time.</div>
|
| 456 |
-
<div id="canary"><div class="msg">Loadingβ¦</div></div>
|
| 457 |
-
</div>
|
| 458 |
-
|
| 459 |
-
<div class="card">
|
| 460 |
-
<h2>Service status</h2>
|
| 461 |
-
<div class="hint">Data sources this Space is currently using, and its connection to the
|
| 462 |
-
analytics Worker.</div>
|
| 463 |
-
<div id="status"><div class="msg">Loadingβ¦</div></div>
|
| 464 |
-
<div style="margin-top:14px;display:flex;gap:8px;flex-wrap:wrap">
|
| 465 |
-
<button class="btn" id="reload-khazna">Reload the Khazna index</button>
|
| 466 |
-
<button class="btn btn-ghost" id="show-local">Show counts held in this container</button>
|
| 467 |
-
<button class="btn btn-ghost" id="probe-worker">Test the connection to Cloudflare</button>
|
| 468 |
-
<button class="btn btn-ghost" id="send-now">Send counts to Cloudflare now</button>
|
| 469 |
-
</div>
|
| 470 |
-
<div id="action-out"></div>
|
| 471 |
-
</div>
|
| 472 |
-
|
| 473 |
-
</div>
|
| 474 |
-
<script>
|
| 475 |
-
// The dashboard tries the Space's server-side proxy first, then falls back to
|
| 476 |
-
// calling the Worker directly from this browser. HF Spaces lose egress to
|
| 477 |
-
// workers.dev for stretches at a time, while the admin's own browser reaches
|
| 478 |
-
// it fine β LibBee hit exactly this and solved it the same way. This page is
|
| 479 |
-
// served only to an authenticated session, so the token is not public.
|
| 480 |
-
const WORKER_URL = "__WORKER_URL__";
|
| 481 |
-
const WORKER_TOKEN = "__WORKER_TOKEN__";
|
| 482 |
-
const SURFACE = """ + json.dumps(SURFACE_LABELS) + """;
|
| 483 |
-
const GAP = """ + json.dumps(GAP_LABELS) + """;
|
| 484 |
-
const esc = s => String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 485 |
-
const num = n => Number(n||0).toLocaleString();
|
| 486 |
-
|
| 487 |
-
let usedFallback = false;
|
| 488 |
-
|
| 489 |
-
async function getDirect(workerPath){
|
| 490 |
-
if(!WORKER_URL || !WORKER_TOKEN) throw new Error('no direct route configured');
|
| 491 |
-
const r = await fetch(WORKER_URL + workerPath, {
|
| 492 |
-
headers: { Authorization: 'Bearer ' + WORKER_TOKEN }
|
| 493 |
-
});
|
| 494 |
-
if(!r.ok) throw new Error('Worker returned HTTP ' + r.status);
|
| 495 |
-
usedFallback = true;
|
| 496 |
-
return r.json();
|
| 497 |
-
}
|
| 498 |
-
|
| 499 |
-
// workerPath is the same data read straight from the Worker. Passing it makes
|
| 500 |
-
// this call survive the Space losing its outbound route to Cloudflare.
|
| 501 |
-
async function get(path, workerPath){
|
| 502 |
-
try {
|
| 503 |
-
const r = await fetch(path);
|
| 504 |
-
if(r.status===401){window.location.href='/admin/login';throw new Error('unauthenticated');}
|
| 505 |
-
const d = await r.json().catch(()=>({}));
|
| 506 |
-
if(!r.ok) throw new Error(d.detail || ('HTTP '+r.status));
|
| 507 |
-
return d;
|
| 508 |
-
} catch(e) {
|
| 509 |
-
if(String(e.message).includes('unauthenticated') || !workerPath) throw e;
|
| 510 |
-
// Space could not reach the Worker; try from here instead.
|
| 511 |
-
return getDirect(workerPath);
|
| 512 |
-
}
|
| 513 |
-
}
|
| 514 |
-
|
| 515 |
-
function fallbackNotice(){
|
| 516 |
-
return usedFallback
|
| 517 |
-
? `<div class="hint" style="color:#92400e;background:#fef3c7;border-radius:8px;padding:7px 10px;margin-bottom:10px">
|
| 518 |
-
Read directly from Cloudflare β this Space could not reach the Worker just now.
|
| 519 |
-
Counts still being collected are buffered and will be sent when the route recovers.
|
| 520 |
-
</div>`
|
| 521 |
-
: '';
|
| 522 |
-
}
|
| 523 |
-
|
| 524 |
-
// ββ Usage ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 525 |
-
let days = 30;
|
| 526 |
-
|
| 527 |
-
async function loadUsage(){
|
| 528 |
-
const el = document.getElementById('usage');
|
| 529 |
-
const gapEl = document.getElementById('gaps');
|
| 530 |
-
el.innerHTML = '<div class="msg">Loadingβ¦</div>';
|
| 531 |
-
try {
|
| 532 |
-
const d = await get('/admin/usage?days='+days, '/usage?days='+days);
|
| 533 |
-
const t = d.totals || {};
|
| 534 |
-
const rows = (d.by_surface || []);
|
| 535 |
-
const max = Math.max(1, ...rows.map(r => r.total || 0));
|
| 536 |
-
|
| 537 |
-
el.innerHTML = fallbackNotice() +
|
| 538 |
-
`<div class="stat-grid" style="margin-bottom:16px">
|
| 539 |
-
<div class="stat"><div class="val">${num(t.total)}</div><div class="lbl">Times used</div></div>
|
| 540 |
-
<div class="stat"><div class="val">${num(t.today)}</div><div class="lbl">Today</div></div>
|
| 541 |
-
<div class="stat"><div class="val">${num(t.miss)}</div><div class="lbl">Unanswered</div></div>
|
| 542 |
-
<div class="stat"><div class="val">${num(t.errors)}</div><div class="lbl">Errors</div></div>
|
| 543 |
-
</div>` +
|
| 544 |
-
(rows.length
|
| 545 |
-
? `<table><thead><tr><th>Tool</th><th>Times used</th><th style="width:35%"></th><th>Unanswered</th><th>Errors</th></tr></thead><tbody>` +
|
| 546 |
-
rows.map(r => `<tr>
|
| 547 |
-
<td>${esc(SURFACE[r.surface] || r.surface)}</td>
|
| 548 |
-
<td><strong>${num(r.total)}</strong></td>
|
| 549 |
-
<td><div class="bar"><span style="width:${Math.round((r.total/max)*100)}%"></span></div></td>
|
| 550 |
-
<td>${num(r.miss)}</td>
|
| 551 |
-
<td>${num(r.errors)}</td>
|
| 552 |
-
</tr>`).join('') +
|
| 553 |
-
`</tbody></table>`
|
| 554 |
-
: `<div class="msg">Nothing recorded in this period yet. Use one of the tools, wait a minute for the next flush, then reload.</div>`);
|
| 555 |
-
|
| 556 |
-
const gaps = d.gaps || [];
|
| 557 |
-
gapEl.innerHTML = gaps.length
|
| 558 |
-
? `<table><thead><tr><th>What was missing</th><th>Tool</th><th>Times</th></tr></thead><tbody>` +
|
| 559 |
-
gaps.map(g => `<tr>
|
| 560 |
-
<td>${esc(GAP[g.gap_code] || g.gap_code)}</td>
|
| 561 |
-
<td>${esc(SURFACE[g.surface] || g.surface)}</td>
|
| 562 |
-
<td><strong>${num(g.c)}</strong></td>
|
| 563 |
-
</tr>`).join('') + `</tbody></table>`
|
| 564 |
-
: `<div class="msg">Nothing flagged in this period.</div>`;
|
| 565 |
-
|
| 566 |
-
} catch(e) {
|
| 567 |
-
el.innerHTML = '<div class="err">Could not load usage: '+esc(e.message)+'</div>';
|
| 568 |
-
gapEl.innerHTML = '';
|
| 569 |
-
}
|
| 570 |
-
}
|
| 571 |
-
|
| 572 |
-
document.querySelectorAll('.range button').forEach(b => {
|
| 573 |
-
b.addEventListener('click', () => {
|
| 574 |
-
document.querySelectorAll('.range button').forEach(x => x.classList.remove('on'));
|
| 575 |
-
b.classList.add('on');
|
| 576 |
-
days = parseInt(b.dataset.days, 10);
|
| 577 |
-
loadUsage();
|
| 578 |
-
});
|
| 579 |
-
});
|
| 580 |
-
|
| 581 |
-
// ββ Canary βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 582 |
-
async function loadCanary(){
|
| 583 |
-
const el = document.getElementById('canary');
|
| 584 |
-
try {
|
| 585 |
-
const d = await get('/admin/canary-status', '/canary/status');
|
| 586 |
-
const runs = d.runs || [];
|
| 587 |
-
el.innerHTML =
|
| 588 |
-
`<div class="msg" style="padding-top:0">${esc(d.summary||'')}</div>` +
|
| 589 |
-
(runs.length
|
| 590 |
-
? `<table><thead><tr><th>When (UTC)</th><th>Tool checked</th><th>Result</th><th>Took</th><th>Detail</th></tr></thead><tbody>` +
|
| 591 |
-
runs.map(r => `<tr>
|
| 592 |
-
<td class="mono">${esc(r.run_at)}</td>
|
| 593 |
-
<td>${esc(r.slot||'')}</td>
|
| 594 |
-
<td><span class="pill ${r.ok?'pill-ok':'pill-bad'}">${r.ok?'Passed':'Failed'}</span></td>
|
| 595 |
-
<td>${r.latency_ms!=null?(r.latency_ms/1000).toFixed(1)+'s':''}</td>
|
| 596 |
-
<td class="mono">${esc(r.error || r.detail || '')}</td>
|
| 597 |
-
</tr>`).join('') + `</tbody></table>`
|
| 598 |
-
: '');
|
| 599 |
-
} catch(e) {
|
| 600 |
-
el.innerHTML = '<div class="err">Could not load health checks: '+esc(e.message)+'</div>';
|
| 601 |
-
}
|
| 602 |
-
}
|
| 603 |
-
|
| 604 |
-
// ββ Status βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 605 |
-
function yesNo(v){ return `<span class="pill ${v?'pill-ok':'pill-warn'}">${v?'Yes':'No'}</span>`; }
|
| 606 |
-
|
| 607 |
-
async function loadStatus(){
|
| 608 |
-
const el = document.getElementById('status');
|
| 609 |
-
try {
|
| 610 |
-
const d = await get('/admin/status');
|
| 611 |
-
const h = d.health || {};
|
| 612 |
-
const src = h.sources || {};
|
| 613 |
-
const k = src.khazna || {};
|
| 614 |
-
const hrs = Math.floor((d.uptime_seconds||0)/3600);
|
| 615 |
-
const mins = Math.floor(((d.uptime_seconds||0)%3600)/60);
|
| 616 |
-
el.innerHTML = `<table><tbody>
|
| 617 |
-
<tr><td>Version</td><td>${esc(h.version||'unknown')}</td></tr>
|
| 618 |
-
<tr><td>Running for</td><td>${hrs}h ${mins}m</td></tr>
|
| 619 |
-
<tr><td>Khazna index loaded</td><td>${yesNo(k.available)}${k.records?' · '+num(k.records)+' records':''}</td></tr>
|
| 620 |
-
<tr><td>KU institution ID set for permissions</td><td>${yesNo(src.oaworks && src.oaworks.ku_ror_set)}</td></tr>
|
| 621 |
-
<tr><td>Repository registry</td><td>${num((src.repository_registry||{}).entries)} entries</td></tr>
|
| 622 |
-
<tr><td>Analytics Worker configured</td><td>${yesNo(d.worker_url_configured)}</td></tr>
|
| 623 |
-
<tr><td>Worker token configured</td><td>${yesNo(d.worker_token_configured)}</td></tr>
|
| 624 |
-
<tr><td>Language model key set</td><td>${yesNo(d.openai_key_set)}</td></tr>
|
| 625 |
-
</tbody></table>`;
|
| 626 |
-
} catch(e) {
|
| 627 |
-
el.innerHTML = '<div class="err">Could not load status: '+esc(e.message)+'</div>';
|
| 628 |
-
}
|
| 629 |
-
}
|
| 630 |
-
|
| 631 |
-
// ββ Actions ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 632 |
-
document.getElementById('reload-khazna').addEventListener('click', async (ev) => {
|
| 633 |
-
const out = document.getElementById('action-out');
|
| 634 |
-
ev.target.disabled = true;
|
| 635 |
-
out.innerHTML = '<div class="msg">Reloadingβ¦</div>';
|
| 636 |
-
try {
|
| 637 |
-
const r = await fetch('/admin/reload-khazna', {method:'POST'});
|
| 638 |
-
const d = await r.json();
|
| 639 |
-
out.innerHTML = d.reloaded
|
| 640 |
-
? `<div class="msg">Reloaded. ${num((d.khazna||{}).records)} records now in the index.</div>`
|
| 641 |
-
: `<div class="err">Reload failed. Check the Space logs for the line beginning "Khazna index download failed".</div>`;
|
| 642 |
-
loadStatus();
|
| 643 |
-
} catch(e) {
|
| 644 |
-
out.innerHTML = '<div class="err">Reload failed: '+esc(e.message)+'</div>';
|
| 645 |
-
}
|
| 646 |
-
ev.target.disabled = false;
|
| 647 |
-
});
|
| 648 |
-
|
| 649 |
-
document.getElementById('show-local').addEventListener('click', async () => {
|
| 650 |
-
const out = document.getElementById('action-out');
|
| 651 |
-
try {
|
| 652 |
-
const d = await get('/admin/local-usage');
|
| 653 |
-
const rows = Object.entries(d.by_surface||{});
|
| 654 |
-
out.innerHTML =
|
| 655 |
-
`<div class="msg">${esc(d.note)} Counting since ${esc(d.since)}. `+
|
| 656 |
-
`${d.pending_deltas} delta(s) waiting to be sent.</div>` +
|
| 657 |
-
(rows.length
|
| 658 |
-
? `<table><thead><tr><th>Tool</th><th>Used</th><th>Unanswered</th><th>Errors</th></tr></thead><tbody>` +
|
| 659 |
-
rows.map(([k,v]) => `<tr><td>${esc(SURFACE[k]||k)}</td><td>${num(v.total)}</td><td>${num(v.miss)}</td><td>${num(v.error)}</td></tr>`).join('') +
|
| 660 |
-
`</tbody></table>`
|
| 661 |
-
: '<div class="msg">Nothing counted since this container started.</div>') +
|
| 662 |
-
((d.recent_flushes||[]).length
|
| 663 |
-
? `<div class="hint" style="margin-top:10px">Last send to the Worker: <span class="mono">${esc(d.recent_flushes[0].outcome)} β ${esc(d.recent_flushes[0].detail)}</span></div>`
|
| 664 |
-
: '');
|
| 665 |
-
} catch(e) {
|
| 666 |
-
out.innerHTML = '<div class="err">'+esc(e.message)+'</div>';
|
| 667 |
-
}
|
| 668 |
-
});
|
| 669 |
-
|
| 670 |
-
// This Space cannot reach Cloudflare, but your browser can β the same route
|
| 671 |
-
// the dashboard reads through. So a manual send goes: Space -> browser -> Worker.
|
| 672 |
-
// Worth doing before a planned redeploy, since counts sit in memory until the
|
| 673 |
-
// Worker's next 6-hourly pull.
|
| 674 |
-
document.getElementById('send-now').addEventListener('click', async (ev) => {
|
| 675 |
-
const out = document.getElementById('action-out');
|
| 676 |
-
ev.target.disabled = true;
|
| 677 |
-
out.innerHTML = '<div class="msg">Collecting countsβ¦</div>';
|
| 678 |
-
try {
|
| 679 |
-
const snap = await get('/admin/usage-export');
|
| 680 |
-
if (!snap.snapshot_id || !snap.deltas.length) {
|
| 681 |
-
out.innerHTML = '<div class="msg">Nothing waiting to be sent.</div>';
|
| 682 |
-
ev.target.disabled = false; return;
|
| 683 |
-
}
|
| 684 |
-
out.innerHTML = `<div class="msg">Sending ${snap.deltas.length} item(s) via this browserβ¦</div>`;
|
| 685 |
-
const r = await fetch(WORKER_URL + '/log', {
|
| 686 |
-
method: 'POST',
|
| 687 |
-
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + WORKER_TOKEN },
|
| 688 |
-
body: JSON.stringify({ deltas: snap.deltas })
|
| 689 |
-
});
|
| 690 |
-
const d = await r.json();
|
| 691 |
-
if (r.ok && d.logged) {
|
| 692 |
-
await fetch('/admin/usage-ack?snapshot_id=' + encodeURIComponent(snap.snapshot_id), {method:'POST'});
|
| 693 |
-
out.innerHTML = `<div class="msg">Sent. ${d.accepted} item(s) stored in Cloudflare.</div>`;
|
| 694 |
-
loadUsage();
|
| 695 |
-
} else {
|
| 696 |
-
out.innerHTML = `<div class="err">Cloudflare refused the counts: ${esc(d.warning||('HTTP '+r.status))}.
|
| 697 |
-
They are held and will be collected on the next scheduled pull.</div>`;
|
| 698 |
-
}
|
| 699 |
-
} catch(e) {
|
| 700 |
-
out.innerHTML = `<div class="err">Could not send: ${esc(e.message)}.
|
| 701 |
-
Counts are held and will be collected on the next scheduled pull.</div>`;
|
| 702 |
-
}
|
| 703 |
-
ev.target.disabled = false;
|
| 704 |
-
});
|
| 705 |
-
|
| 706 |
-
document.getElementById('probe-worker').addEventListener('click', async (ev) => {
|
| 707 |
-
const out = document.getElementById('action-out');
|
| 708 |
-
ev.target.disabled = true;
|
| 709 |
-
out.innerHTML = '<div class="msg">Testing three timesβ¦</div>';
|
| 710 |
-
try {
|
| 711 |
-
const d = await get('/admin/worker-probe');
|
| 712 |
-
const rows = (d.attempts||[]).map(a =>
|
| 713 |
-
`<tr><td>Attempt ${a.attempt}</td>
|
| 714 |
-
<td><span class="pill ${a.ok?'pill-ok':'pill-bad'}">${a.ok?'Reached':'Failed'}</span></td>
|
| 715 |
-
<td>${a.ms} ms</td><td class="mono">${esc(a.error||'')}</td></tr>`).join('');
|
| 716 |
-
out.innerHTML =
|
| 717 |
-
`<div class="msg">Verdict: <strong>${esc(d.verdict)}</strong></div>` +
|
| 718 |
-
`<table><tbody>${rows}</tbody></table>` +
|
| 719 |
-
`<div class="hint" style="margin-top:8px">${esc(d.note||'')}</div>`;
|
| 720 |
-
} catch(e) {
|
| 721 |
-
out.innerHTML = '<div class="err">'+esc(e.message)+'</div>';
|
| 722 |
-
}
|
| 723 |
-
ev.target.disabled = false;
|
| 724 |
-
});
|
| 725 |
-
|
| 726 |
-
loadUsage(); loadCanary(); loadStatus();
|
| 727 |
-
</script></body></html>"""
|
| 728 |
-
return HTMLResponse(
|
| 729 |
-
page.replace("__WORKER_URL__", WORKER_URL)
|
| 730 |
-
.replace("__WORKER_TOKEN__", WORKER_TOKEN)
|
| 731 |
)
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
admin.py β ResearchBee admin dashboard
|
| 3 |
+
|
| 4 |
+
Ported from LibBee v3.8's admin module so the two dashboards behave
|
| 5 |
+
identically: same cookie-session login, same 5-attempt IP lockout, same
|
| 6 |
+
server-rendered single page, same visual language. Someone who
|
| 7 |
+
administers LibBee should not have to learn a second interface.
|
| 8 |
+
|
| 9 |
+
What is deliberately different
|
| 10 |
+
------------------------------
|
| 11 |
+
LibBee's dashboard shows recent questions. This one cannot, because
|
| 12 |
+
ResearchBee never stores question text β see usage.py. The equivalent
|
| 13 |
+
panels here answer "how much of each tool is being used" and "how often
|
| 14 |
+
could we not answer", both from counters.
|
| 15 |
+
|
| 16 |
+
Mount with:
|
| 17 |
+
from admin import router as admin_router
|
| 18 |
+
app.include_router(admin_router, prefix="/admin")
|
| 19 |
+
|
| 20 |
+
Environment:
|
| 21 |
+
RESEARCHBEE_ADMIN_PASSWORD required β the dashboard is unreachable without it
|
| 22 |
+
CLOUDFLARE_WORKER_URL the LibBee Worker (shared)
|
| 23 |
+
CLOUDFLARE_WORKER_TOKEN must equal the Worker's ANALYTICS_TOKEN
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import json
|
| 29 |
+
import os
|
| 30 |
+
import secrets
|
| 31 |
+
import time
|
| 32 |
+
from typing import Any, Dict, Optional
|
| 33 |
+
|
| 34 |
+
from fastapi import APIRouter, HTTPException, Request, Response
|
| 35 |
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
| 36 |
+
from pydantic import BaseModel
|
| 37 |
+
|
| 38 |
+
router = APIRouter()
|
| 39 |
+
|
| 40 |
+
_START_TIME = time.time()
|
| 41 |
+
|
| 42 |
+
ADMIN_PASSWORD = (os.environ.get("RESEARCHBEE_ADMIN_PASSWORD") or "").strip()
|
| 43 |
+
WORKER_URL = (os.environ.get("CLOUDFLARE_WORKER_URL") or "").strip().strip('"').strip("'").rstrip("/")
|
| 44 |
+
WORKER_TOKEN = (os.environ.get("CLOUDFLARE_WORKER_TOKEN") or "").strip()
|
| 45 |
+
|
| 46 |
+
_SESSIONS_FILE = "/tmp/researchbee_admin_sessions.json"
|
| 47 |
+
|
| 48 |
+
admin_sessions: Dict[str, float] = {}
|
| 49 |
+
|
| 50 |
+
# Failed logins per IP. Five misses inside the window locks that IP out
|
| 51 |
+
# for fifteen minutes β enough to make guessing a shared password
|
| 52 |
+
# impractical without locking out a librarian who fat-fingers it twice.
|
| 53 |
+
_login_attempts: Dict[str, list] = {}
|
| 54 |
+
_MAX_LOGIN_ATTEMPTS = 5
|
| 55 |
+
_LOCKOUT_WINDOW = 300
|
| 56 |
+
_LOCKOUT_DURATION = 900
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ββ Sessions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 60 |
+
|
| 61 |
+
def _load_sessions() -> None:
|
| 62 |
+
try:
|
| 63 |
+
with open(_SESSIONS_FILE, "r", encoding="utf-8") as f:
|
| 64 |
+
data = json.load(f)
|
| 65 |
+
now = time.time()
|
| 66 |
+
admin_sessions.update({
|
| 67 |
+
t: exp for t, exp in data.items()
|
| 68 |
+
if isinstance(exp, (int, float)) and exp > now
|
| 69 |
+
})
|
| 70 |
+
except Exception:
|
| 71 |
+
pass
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _save_sessions() -> None:
|
| 75 |
+
try:
|
| 76 |
+
now = time.time()
|
| 77 |
+
with open(_SESSIONS_FILE, "w", encoding="utf-8") as f:
|
| 78 |
+
json.dump({t: e for t, e in admin_sessions.items() if e > now}, f)
|
| 79 |
+
except Exception:
|
| 80 |
+
pass
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _cleanup_sessions() -> None:
|
| 84 |
+
now = time.time()
|
| 85 |
+
for t in [t for t, exp in admin_sessions.items() if exp <= now]:
|
| 86 |
+
admin_sessions.pop(t, None)
|
| 87 |
+
_load_sessions()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _has_session(request: Request) -> bool:
|
| 91 |
+
_cleanup_sessions()
|
| 92 |
+
token = request.cookies.get("rb_admin_session")
|
| 93 |
+
return bool(token and token in admin_sessions)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _require_session(request: Request) -> None:
|
| 97 |
+
if not _has_session(request):
|
| 98 |
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class LoginRequest(BaseModel):
|
| 102 |
+
password: str
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ββ Auth ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 106 |
+
|
| 107 |
+
@router.post("/auth")
|
| 108 |
+
async def admin_auth(req: LoginRequest, request: Request):
|
| 109 |
+
client_ip = request.client.host if request.client else "unknown"
|
| 110 |
+
now = time.time()
|
| 111 |
+
|
| 112 |
+
attempts = [t for t in _login_attempts.get(client_ip, []) if now - t < _LOCKOUT_WINDOW]
|
| 113 |
+
if len(attempts) >= _MAX_LOGIN_ATTEMPTS:
|
| 114 |
+
wait = int(_LOCKOUT_DURATION - (now - attempts[0]))
|
| 115 |
+
raise HTTPException(429, f"Too many failed attempts. Try again in {wait} seconds.")
|
| 116 |
+
|
| 117 |
+
if not ADMIN_PASSWORD or not secrets.compare_digest(
|
| 118 |
+
req.password.encode(), ADMIN_PASSWORD.encode()
|
| 119 |
+
):
|
| 120 |
+
attempts.append(now)
|
| 121 |
+
_login_attempts[client_ip] = attempts
|
| 122 |
+
remaining = _MAX_LOGIN_ATTEMPTS - len(attempts)
|
| 123 |
+
if not ADMIN_PASSWORD:
|
| 124 |
+
detail = "No admin password is set on this Space. Add RESEARCHBEE_ADMIN_PASSWORD to the Space secrets."
|
| 125 |
+
elif remaining <= 0:
|
| 126 |
+
detail = f"Too many failed attempts. Try again in {_LOCKOUT_DURATION // 60} minutes."
|
| 127 |
+
else:
|
| 128 |
+
detail = f"Incorrect password. {remaining} attempt{'s' if remaining != 1 else ''} left before lockout."
|
| 129 |
+
raise HTTPException(401, detail)
|
| 130 |
+
|
| 131 |
+
_login_attempts.pop(client_ip, None)
|
| 132 |
+
token = secrets.token_hex(32)
|
| 133 |
+
admin_sessions[token] = now + 86400
|
| 134 |
+
_save_sessions()
|
| 135 |
+
resp = Response(content='{"status":"ok"}', media_type="application/json")
|
| 136 |
+
resp.set_cookie("rb_admin_session", token, httponly=True,
|
| 137 |
+
max_age=86400, samesite="lax", secure=True)
|
| 138 |
+
return resp
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@router.get("/logout")
|
| 142 |
+
async def admin_logout(request: Request):
|
| 143 |
+
token = request.cookies.get("rb_admin_session")
|
| 144 |
+
if token:
|
| 145 |
+
admin_sessions.pop(token, None)
|
| 146 |
+
_save_sessions()
|
| 147 |
+
resp = RedirectResponse(url="/admin/login")
|
| 148 |
+
resp.delete_cookie("rb_admin_session")
|
| 149 |
+
return resp
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ββ Worker proxies ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 153 |
+
# The Worker URL and token stay server-side. The browser only ever talks
|
| 154 |
+
# to this Space, so neither secret reaches the page.
|
| 155 |
+
|
| 156 |
+
async def _worker_get(path: str, params: Optional[dict] = None):
|
| 157 |
+
if not WORKER_URL:
|
| 158 |
+
raise HTTPException(503, "CLOUDFLARE_WORKER_URL is not set on this Space")
|
| 159 |
+
import httpx
|
| 160 |
+
headers = {"Authorization": f"Bearer {WORKER_TOKEN}"} if WORKER_TOKEN else {}
|
| 161 |
+
try:
|
| 162 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 163 |
+
r = await client.get(f"{WORKER_URL}{path}", params=params or {}, headers=headers)
|
| 164 |
+
return r.json()
|
| 165 |
+
except HTTPException:
|
| 166 |
+
raise
|
| 167 |
+
except Exception as exc:
|
| 168 |
+
raise HTTPException(502, f"Worker unreachable: {type(exc).__name__}: {exc}")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@router.get("/usage")
|
| 172 |
+
async def admin_usage(request: Request, days: int = 30):
|
| 173 |
+
_require_session(request)
|
| 174 |
+
return await _worker_get("/rb/usage", {"days": max(0, min(days, 3650))})
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@router.get("/canary-status")
|
| 178 |
+
async def admin_canary_status(request: Request):
|
| 179 |
+
_require_session(request)
|
| 180 |
+
return await _worker_get("/rb/canary/status")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@router.get("/local-usage")
|
| 184 |
+
async def admin_local_usage(request: Request):
|
| 185 |
+
"""Counts held in this container since it last started. Useful when
|
| 186 |
+
the Worker is unreachable, and as a cross-check that counting is
|
| 187 |
+
actually happening before blaming the Worker."""
|
| 188 |
+
_require_session(request)
|
| 189 |
+
from usage import usage
|
| 190 |
+
return usage.snapshot()
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ββ Pull endpoints (called by the Cloudflare Worker, not by a browser) β
|
| 194 |
+
# Authenticated with the shared token rather than an admin session,
|
| 195 |
+
# because the caller is a machine. This is the direction that works:
|
| 196 |
+
# Cloudflare can reach this Space even though this Space cannot reach
|
| 197 |
+
# Cloudflare.
|
| 198 |
+
|
| 199 |
+
def _require_worker_token(request: Request) -> None:
|
| 200 |
+
if not WORKER_TOKEN:
|
| 201 |
+
raise HTTPException(503, "CLOUDFLARE_WORKER_TOKEN is not set on this Space")
|
| 202 |
+
supplied = request.headers.get("authorization", "")
|
| 203 |
+
if not secrets.compare_digest(supplied, f"Bearer {WORKER_TOKEN}"):
|
| 204 |
+
raise HTTPException(401, "Unauthorized")
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _worker_or_session(request: Request) -> None:
|
| 208 |
+
"""Accept either the Worker's token or a signed-in admin, so the same
|
| 209 |
+
endpoints back both the cron pull and the dashboard's manual send."""
|
| 210 |
+
if _has_session(request):
|
| 211 |
+
return
|
| 212 |
+
_require_worker_token(request)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@router.get("/usage-export")
|
| 216 |
+
async def usage_export(request: Request):
|
| 217 |
+
"""Hand over accumulated counts. Counts are held, not deleted, until
|
| 218 |
+
the caller confirms with /usage-ack β so a caller that reads them and
|
| 219 |
+
then fails to store them cannot destroy them."""
|
| 220 |
+
_worker_or_session(request)
|
| 221 |
+
from usage import usage
|
| 222 |
+
return usage.export_snapshot()
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@router.post("/usage-ack")
|
| 226 |
+
async def usage_ack(request: Request, snapshot_id: str):
|
| 227 |
+
"""Confirm a snapshot reached storage. Only now is it discarded."""
|
| 228 |
+
_worker_or_session(request)
|
| 229 |
+
from usage import usage
|
| 230 |
+
return {"acknowledged": usage.ack_snapshot(snapshot_id)}
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
@router.get("/worker-probe")
|
| 234 |
+
async def admin_worker_probe(request: Request):
|
| 235 |
+
"""Is this Space's route to the Worker broken, or just slow?
|
| 236 |
+
|
| 237 |
+
HF Spaces lose connectivity to workers.dev for stretches at a time β
|
| 238 |
+
LibBee hit the same thing and moved its analytics writes into the
|
| 239 |
+
browser because of it. Three attempts with timings tell you which
|
| 240 |
+
case you are in: three identical failures is a blocked route, a mix
|
| 241 |
+
is the intermittent case, which the flush buffer already absorbs.
|
| 242 |
+
"""
|
| 243 |
+
_require_session(request)
|
| 244 |
+
import httpx, time as _t
|
| 245 |
+
if not WORKER_URL:
|
| 246 |
+
return {"error": "CLOUDFLARE_WORKER_URL is not set on this Space"}
|
| 247 |
+
attempts = []
|
| 248 |
+
for i in range(3):
|
| 249 |
+
t0 = _t.time()
|
| 250 |
+
try:
|
| 251 |
+
async with httpx.AsyncClient(timeout=20) as client:
|
| 252 |
+
r = await client.get(f"{WORKER_URL}/version")
|
| 253 |
+
attempts.append({"attempt": i + 1, "ok": r.status_code == 200,
|
| 254 |
+
"status": r.status_code,
|
| 255 |
+
"ms": int((_t.time() - t0) * 1000)})
|
| 256 |
+
except Exception as exc:
|
| 257 |
+
attempts.append({"attempt": i + 1, "ok": False,
|
| 258 |
+
"error": f"{type(exc).__name__}: {exc}"[:160],
|
| 259 |
+
"ms": int((_t.time() - t0) * 1000)})
|
| 260 |
+
ok = sum(1 for a in attempts if a.get("ok"))
|
| 261 |
+
return {
|
| 262 |
+
"worker_url": WORKER_URL,
|
| 263 |
+
"attempts": attempts,
|
| 264 |
+
"verdict": ("reachable" if ok == 3 else
|
| 265 |
+
"intermittent" if ok else "unreachable from this Space"),
|
| 266 |
+
"note": ("Counts are buffered and retried every 60 seconds, so patchy "
|
| 267 |
+
"egress delays the dashboard without losing data. Dashboard "
|
| 268 |
+
"reads fall back to your browser, which usually reaches the "
|
| 269 |
+
"Worker even when this Space cannot."),
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
@router.get("/status")
|
| 274 |
+
async def admin_status(request: Request):
|
| 275 |
+
_require_session(request)
|
| 276 |
+
import app as rb_app
|
| 277 |
+
|
| 278 |
+
out: Dict[str, Any] = {
|
| 279 |
+
"uptime_seconds": int(time.time() - _START_TIME),
|
| 280 |
+
"worker_url_configured": bool(WORKER_URL),
|
| 281 |
+
"worker_token_configured": bool(WORKER_TOKEN),
|
| 282 |
+
"admin_password_set": bool(ADMIN_PASSWORD),
|
| 283 |
+
"openai_key_set": bool(os.environ.get("OPENAI_API_KEY")),
|
| 284 |
+
}
|
| 285 |
+
try:
|
| 286 |
+
out["health"] = await rb_app.health()
|
| 287 |
+
except Exception as exc:
|
| 288 |
+
out["health"] = {"error": f"{type(exc).__name__}: {exc}"}
|
| 289 |
+
return out
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
@router.post("/reload-khazna")
|
| 293 |
+
async def admin_reload_khazna(request: Request):
|
| 294 |
+
"""Session-protected wrapper around the existing reload endpoint, so
|
| 295 |
+
the button on this page works without exposing an unauthenticated
|
| 296 |
+
admin path."""
|
| 297 |
+
_require_session(request)
|
| 298 |
+
import asyncio
|
| 299 |
+
from app import khazna_index
|
| 300 |
+
ok = await asyncio.to_thread(khazna_index.reload)
|
| 301 |
+
return {"reloaded": ok, "khazna": khazna_index.status}
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# ββ Login page ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 305 |
+
|
| 306 |
+
@router.get("/login", response_class=HTMLResponse)
|
| 307 |
+
async def admin_login_page(request: Request):
|
| 308 |
+
if _has_session(request):
|
| 309 |
+
return RedirectResponse(url="/admin")
|
| 310 |
+
return HTMLResponse("""<!DOCTYPE html>
|
| 311 |
+
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 312 |
+
<title>ResearchBee β Admin</title>
|
| 313 |
+
<style>
|
| 314 |
+
*{box-sizing:border-box;margin:0;padding:0}
|
| 315 |
+
body{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;display:flex;justify-content:center;align-items:center;min-height:100vh}
|
| 316 |
+
.box{background:#fff;border-radius:16px;padding:40px;width:360px;box-shadow:0 4px 24px rgba(0,0,0,.1);text-align:center}
|
| 317 |
+
h1{color:#003366;font-size:1.3rem;margin-bottom:6px}
|
| 318 |
+
.sub{color:#6b7280;font-size:.84rem;margin-bottom:24px}
|
| 319 |
+
input{width:100%;padding:12px 14px;border:1.5px solid #d1d5db;border-radius:10px;font-size:.92rem;margin-bottom:14px}
|
| 320 |
+
input:focus{outline:none;border-color:#003366}
|
| 321 |
+
.btn{width:100%;padding:12px;background:#003366;color:#fff;border:none;border-radius:10px;font-weight:700;font-size:.92rem;cursor:pointer}
|
| 322 |
+
.btn:hover{background:#004488}
|
| 323 |
+
.err{color:#dc2626;font-size:.82rem;margin-bottom:10px;display:none}
|
| 324 |
+
</style></head><body>
|
| 325 |
+
<div class="box">
|
| 326 |
+
<h1>π ResearchBee Admin</h1>
|
| 327 |
+
<div class="sub">KU Library publishing assistant</div>
|
| 328 |
+
<div class="err" id="err"></div>
|
| 329 |
+
<form onsubmit="return go(event)">
|
| 330 |
+
<input type="password" id="pw" placeholder="Admin password" autofocus>
|
| 331 |
+
<button type="submit" class="btn">Sign in</button>
|
| 332 |
+
</form>
|
| 333 |
+
</div>
|
| 334 |
+
<script>
|
| 335 |
+
async function go(e){
|
| 336 |
+
e.preventDefault();
|
| 337 |
+
const r=await fetch('/admin/auth',{method:'POST',headers:{'Content-Type':'application/json'},
|
| 338 |
+
body:JSON.stringify({password:document.getElementById('pw').value})});
|
| 339 |
+
if(r.ok){window.location.href='/admin';return false;}
|
| 340 |
+
const d=await r.json().catch(()=>({}));
|
| 341 |
+
const el=document.getElementById('err');
|
| 342 |
+
el.textContent=d.detail||'Incorrect password.';el.style.display='block';
|
| 343 |
+
document.getElementById('pw').value='';document.getElementById('pw').focus();
|
| 344 |
+
return false;
|
| 345 |
+
}
|
| 346 |
+
</script></body></html>""")
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# ββ Dashboard βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 350 |
+
|
| 351 |
+
SURFACE_LABELS = {
|
| 352 |
+
"journal_subject": "Find a journal β browse by subject",
|
| 353 |
+
"journal_manuscript": "Find a journal β analyse manuscript",
|
| 354 |
+
"licence_deposit": "Share your article (deposit rights)",
|
| 355 |
+
"data_repository": "Deposit your data (repository finder)",
|
| 356 |
+
"cover_letter": "Cover letter generator",
|
| 357 |
+
"chat": "Chat assistant",
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
GAP_LABELS = {
|
| 361 |
+
"scimago_miss": "Subject not found in the journal index",
|
| 362 |
+
"subject_unrecognised": "Subject did not map to any SCImago category",
|
| 363 |
+
# Model named a category that does not exist. High counts here mean the
|
| 364 |
+
# vocabulary is missing terms researchers actually use (e.g. criminology).
|
| 365 |
+
"category_invented": "Subject has no category in Scopus/SCImago",
|
| 366 |
+
# Answered, but with the wrong kind of answer: no category matched, so the
|
| 367 |
+
# list is the whole subject AREA ranked by h-index. A high count here means
|
| 368 |
+
# the vocabulary is missing terms researchers actually use.
|
| 369 |
+
"area_fallback": "Showed a broad subject area, not a specific category",
|
| 370 |
+
"oaworks_404": "No permissions record for that article",
|
| 371 |
+
"oaworks_unconfirmed": "Permissions record found but policy not confirmed",
|
| 372 |
+
"doi_unparseable": "Could not read a DOI from what was entered",
|
| 373 |
+
"khazna_no_doi": "No DOI supplied, so Khazna could not be checked",
|
| 374 |
+
"khazna_unavailable": "Khazna index not loaded",
|
| 375 |
+
"no_apc_agreement": "No KU APC agreement for that publisher",
|
| 376 |
+
"no_repository_match": "No repository matched the dataset",
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
@router.get("", response_class=HTMLResponse)
|
| 381 |
+
async def admin_dashboard(request: Request):
|
| 382 |
+
if not _has_session(request):
|
| 383 |
+
return RedirectResponse(url="/admin/login")
|
| 384 |
+
|
| 385 |
+
page = """<!DOCTYPE html>
|
| 386 |
+
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 387 |
+
<title>ResearchBee β Admin</title>
|
| 388 |
+
<style>
|
| 389 |
+
*{box-sizing:border-box;margin:0;padding:0}
|
| 390 |
+
body{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;color:#1a1a2e}
|
| 391 |
+
.topbar{background:#003366;color:#fff;padding:12px 20px;display:flex;align-items:center;justify-content:space-between}
|
| 392 |
+
.topbar h1{font-size:1.1rem;font-weight:700}
|
| 393 |
+
.topbar a{color:#C8A951;font-size:.82rem;text-decoration:none;padding:5px 12px;border:1px solid #C8A951;border-radius:8px}
|
| 394 |
+
.main{max-width:900px;margin:24px auto;padding:0 16px;display:flex;flex-direction:column;gap:20px}
|
| 395 |
+
.card{background:#fff;border-radius:14px;padding:20px;box-shadow:0 2px 12px rgba(0,0,0,.07)}
|
| 396 |
+
.card h2{font-size:1rem;font-weight:700;color:#003366;margin-bottom:6px}
|
| 397 |
+
.card .hint{font-size:.78rem;color:#6b7280;margin-bottom:14px;line-height:1.5}
|
| 398 |
+
.btn{padding:9px 20px;background:#003366;color:#fff;border:none;border-radius:8px;font-weight:700;cursor:pointer;font-size:.88rem}
|
| 399 |
+
.btn:hover{background:#004488}
|
| 400 |
+
.btn-ghost{background:#fff;color:#003366;border:1.5px solid #d1d5db}
|
| 401 |
+
.btn-ghost:hover{background:#f8fafc}
|
| 402 |
+
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px}
|
| 403 |
+
.stat{background:#f8fafc;border-radius:10px;padding:14px;text-align:center;border:1px solid #e5e7eb}
|
| 404 |
+
.stat .val{font-size:1.6rem;font-weight:800;color:#003366}
|
| 405 |
+
.stat .lbl{font-size:.74rem;color:#6b7280;margin-top:2px}
|
| 406 |
+
table{width:100%;border-collapse:collapse;font-size:.84rem}
|
| 407 |
+
th{text-align:left;font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;border-bottom:1px solid #e5e7eb;padding:7px 8px}
|
| 408 |
+
td{padding:7px 8px;border-bottom:1px solid #f1f5f9}
|
| 409 |
+
tr:last-child td{border-bottom:none}
|
| 410 |
+
.bar{height:8px;background:#e5e7eb;border-radius:4px;overflow:hidden;min-width:60px}
|
| 411 |
+
.bar span{display:block;height:100%;background:#003366}
|
| 412 |
+
.pill{display:inline-block;padding:2px 9px;border-radius:20px;font-size:.72rem;font-weight:700}
|
| 413 |
+
.pill-ok{background:#dcfce7;color:#15803d}
|
| 414 |
+
.pill-bad{background:#fee2e2;color:#b91c1c}
|
| 415 |
+
.pill-warn{background:#fef3c7;color:#92400e}
|
| 416 |
+
.range{display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap}
|
| 417 |
+
.range button{padding:4px 12px;border:1.5px solid #d1d5db;background:#fff;border-radius:20px;font-size:.78rem;cursor:pointer;color:#6b7280}
|
| 418 |
+
.range button.on{border-color:#003366;background:#003366;color:#fff;font-weight:700}
|
| 419 |
+
.msg{font-size:.82rem;color:#6b7280;padding:8px 0}
|
| 420 |
+
.err{font-size:.82rem;color:#b91c1c;padding:8px 0}
|
| 421 |
+
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.76rem}
|
| 422 |
+
</style></head><body>
|
| 423 |
+
<div class="topbar">
|
| 424 |
+
<h1>π ResearchBee β Admin</h1>
|
| 425 |
+
<a href="/admin/logout">Sign out</a>
|
| 426 |
+
</div>
|
| 427 |
+
<div class="main">
|
| 428 |
+
|
| 429 |
+
<div class="card">
|
| 430 |
+
<h2>Tool usage</h2>
|
| 431 |
+
<div class="hint">How many times each tool was used. Counts only β ResearchBee never
|
| 432 |
+
stores what anyone typed, so there is no question log here by design.
|
| 433 |
+
Keep-alive traffic is excluded.</div>
|
| 434 |
+
<div class="range">
|
| 435 |
+
<button data-days="7">7 days</button>
|
| 436 |
+
<button data-days="30" class="on">30 days</button>
|
| 437 |
+
<button data-days="90">90 days</button>
|
| 438 |
+
<button data-days="0">All time</button>
|
| 439 |
+
</div>
|
| 440 |
+
<div id="usage"><div class="msg">Loadingβ¦</div></div>
|
| 441 |
+
</div>
|
| 442 |
+
|
| 443 |
+
<div class="card">
|
| 444 |
+
<h2>Questions we could not answer</h2>
|
| 445 |
+
<div class="hint">Requests that completed but found nothing to answer with β a subject
|
| 446 |
+
missing from the journal index, an article with no permissions record. Each one is a
|
| 447 |
+
candidate for improving a data source.</div>
|
| 448 |
+
<div id="gaps"><div class="msg">Loadingβ¦</div></div>
|
| 449 |
+
</div>
|
| 450 |
+
|
| 451 |
+
<div class="card">
|
| 452 |
+
<h2>Health checks</h2>
|
| 453 |
+
<div class="hint">Every six hours the Worker asks ResearchBee a real question, rotating
|
| 454 |
+
through the four tools. This keeps the Space awake and proves a different pipeline
|
| 455 |
+
still works each time.</div>
|
| 456 |
+
<div id="canary"><div class="msg">Loadingβ¦</div></div>
|
| 457 |
+
</div>
|
| 458 |
+
|
| 459 |
+
<div class="card">
|
| 460 |
+
<h2>Service status</h2>
|
| 461 |
+
<div class="hint">Data sources this Space is currently using, and its connection to the
|
| 462 |
+
analytics Worker.</div>
|
| 463 |
+
<div id="status"><div class="msg">Loadingβ¦</div></div>
|
| 464 |
+
<div style="margin-top:14px;display:flex;gap:8px;flex-wrap:wrap">
|
| 465 |
+
<button class="btn" id="reload-khazna">Reload the Khazna index</button>
|
| 466 |
+
<button class="btn btn-ghost" id="show-local">Show counts held in this container</button>
|
| 467 |
+
<button class="btn btn-ghost" id="probe-worker">Test the connection to Cloudflare</button>
|
| 468 |
+
<button class="btn btn-ghost" id="send-now">Send counts to Cloudflare now</button>
|
| 469 |
+
</div>
|
| 470 |
+
<div id="action-out"></div>
|
| 471 |
+
</div>
|
| 472 |
+
|
| 473 |
+
</div>
|
| 474 |
+
<script>
|
| 475 |
+
// The dashboard tries the Space's server-side proxy first, then falls back to
|
| 476 |
+
// calling the Worker directly from this browser. HF Spaces lose egress to
|
| 477 |
+
// workers.dev for stretches at a time, while the admin's own browser reaches
|
| 478 |
+
// it fine β LibBee hit exactly this and solved it the same way. This page is
|
| 479 |
+
// served only to an authenticated session, so the token is not public.
|
| 480 |
+
const WORKER_URL = "__WORKER_URL__";
|
| 481 |
+
const WORKER_TOKEN = "__WORKER_TOKEN__";
|
| 482 |
+
const SURFACE = """ + json.dumps(SURFACE_LABELS) + """;
|
| 483 |
+
const GAP = """ + json.dumps(GAP_LABELS) + """;
|
| 484 |
+
const esc = s => String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 485 |
+
const num = n => Number(n||0).toLocaleString();
|
| 486 |
+
|
| 487 |
+
let usedFallback = false;
|
| 488 |
+
|
| 489 |
+
async function getDirect(workerPath){
|
| 490 |
+
if(!WORKER_URL || !WORKER_TOKEN) throw new Error('no direct route configured');
|
| 491 |
+
const r = await fetch(WORKER_URL + workerPath, {
|
| 492 |
+
headers: { Authorization: 'Bearer ' + WORKER_TOKEN }
|
| 493 |
+
});
|
| 494 |
+
if(!r.ok) throw new Error('Worker returned HTTP ' + r.status);
|
| 495 |
+
usedFallback = true;
|
| 496 |
+
return r.json();
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
// workerPath is the same data read straight from the Worker. Passing it makes
|
| 500 |
+
// this call survive the Space losing its outbound route to Cloudflare.
|
| 501 |
+
async function get(path, workerPath){
|
| 502 |
+
try {
|
| 503 |
+
const r = await fetch(path);
|
| 504 |
+
if(r.status===401){window.location.href='/admin/login';throw new Error('unauthenticated');}
|
| 505 |
+
const d = await r.json().catch(()=>({}));
|
| 506 |
+
if(!r.ok) throw new Error(d.detail || ('HTTP '+r.status));
|
| 507 |
+
return d;
|
| 508 |
+
} catch(e) {
|
| 509 |
+
if(String(e.message).includes('unauthenticated') || !workerPath) throw e;
|
| 510 |
+
// Space could not reach the Worker; try from here instead.
|
| 511 |
+
return getDirect(workerPath);
|
| 512 |
+
}
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
function fallbackNotice(){
|
| 516 |
+
return usedFallback
|
| 517 |
+
? `<div class="hint" style="color:#92400e;background:#fef3c7;border-radius:8px;padding:7px 10px;margin-bottom:10px">
|
| 518 |
+
Read directly from Cloudflare β this Space could not reach the Worker just now.
|
| 519 |
+
Counts still being collected are buffered and will be sent when the route recovers.
|
| 520 |
+
</div>`
|
| 521 |
+
: '';
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
// ββ Usage ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 525 |
+
let days = 30;
|
| 526 |
+
|
| 527 |
+
async function loadUsage(){
|
| 528 |
+
const el = document.getElementById('usage');
|
| 529 |
+
const gapEl = document.getElementById('gaps');
|
| 530 |
+
el.innerHTML = '<div class="msg">Loadingβ¦</div>';
|
| 531 |
+
try {
|
| 532 |
+
const d = await get('/admin/usage?days='+days, '/usage?days='+days);
|
| 533 |
+
const t = d.totals || {};
|
| 534 |
+
const rows = (d.by_surface || []);
|
| 535 |
+
const max = Math.max(1, ...rows.map(r => r.total || 0));
|
| 536 |
+
|
| 537 |
+
el.innerHTML = fallbackNotice() +
|
| 538 |
+
`<div class="stat-grid" style="margin-bottom:16px">
|
| 539 |
+
<div class="stat"><div class="val">${num(t.total)}</div><div class="lbl">Times used</div></div>
|
| 540 |
+
<div class="stat"><div class="val">${num(t.today)}</div><div class="lbl">Today</div></div>
|
| 541 |
+
<div class="stat"><div class="val">${num(t.miss)}</div><div class="lbl">Unanswered</div></div>
|
| 542 |
+
<div class="stat"><div class="val">${num(t.errors)}</div><div class="lbl">Errors</div></div>
|
| 543 |
+
</div>` +
|
| 544 |
+
(rows.length
|
| 545 |
+
? `<table><thead><tr><th>Tool</th><th>Times used</th><th style="width:35%"></th><th>Unanswered</th><th>Errors</th></tr></thead><tbody>` +
|
| 546 |
+
rows.map(r => `<tr>
|
| 547 |
+
<td>${esc(SURFACE[r.surface] || r.surface)}</td>
|
| 548 |
+
<td><strong>${num(r.total)}</strong></td>
|
| 549 |
+
<td><div class="bar"><span style="width:${Math.round((r.total/max)*100)}%"></span></div></td>
|
| 550 |
+
<td>${num(r.miss)}</td>
|
| 551 |
+
<td>${num(r.errors)}</td>
|
| 552 |
+
</tr>`).join('') +
|
| 553 |
+
`</tbody></table>`
|
| 554 |
+
: `<div class="msg">Nothing recorded in this period yet. Use one of the tools, wait a minute for the next flush, then reload.</div>`);
|
| 555 |
+
|
| 556 |
+
const gaps = d.gaps || [];
|
| 557 |
+
gapEl.innerHTML = gaps.length
|
| 558 |
+
? `<table><thead><tr><th>What was missing</th><th>Tool</th><th>Times</th></tr></thead><tbody>` +
|
| 559 |
+
gaps.map(g => `<tr>
|
| 560 |
+
<td>${esc(GAP[g.gap_code] || g.gap_code)}</td>
|
| 561 |
+
<td>${esc(SURFACE[g.surface] || g.surface)}</td>
|
| 562 |
+
<td><strong>${num(g.c)}</strong></td>
|
| 563 |
+
</tr>`).join('') + `</tbody></table>`
|
| 564 |
+
: `<div class="msg">Nothing flagged in this period.</div>`;
|
| 565 |
+
|
| 566 |
+
} catch(e) {
|
| 567 |
+
el.innerHTML = '<div class="err">Could not load usage: '+esc(e.message)+'</div>';
|
| 568 |
+
gapEl.innerHTML = '';
|
| 569 |
+
}
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
document.querySelectorAll('.range button').forEach(b => {
|
| 573 |
+
b.addEventListener('click', () => {
|
| 574 |
+
document.querySelectorAll('.range button').forEach(x => x.classList.remove('on'));
|
| 575 |
+
b.classList.add('on');
|
| 576 |
+
days = parseInt(b.dataset.days, 10);
|
| 577 |
+
loadUsage();
|
| 578 |
+
});
|
| 579 |
+
});
|
| 580 |
+
|
| 581 |
+
// ββ Canary βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 582 |
+
async function loadCanary(){
|
| 583 |
+
const el = document.getElementById('canary');
|
| 584 |
+
try {
|
| 585 |
+
const d = await get('/admin/canary-status', '/canary/status');
|
| 586 |
+
const runs = d.runs || [];
|
| 587 |
+
el.innerHTML =
|
| 588 |
+
`<div class="msg" style="padding-top:0">${esc(d.summary||'')}</div>` +
|
| 589 |
+
(runs.length
|
| 590 |
+
? `<table><thead><tr><th>When (UTC)</th><th>Tool checked</th><th>Result</th><th>Took</th><th>Detail</th></tr></thead><tbody>` +
|
| 591 |
+
runs.map(r => `<tr>
|
| 592 |
+
<td class="mono">${esc(r.run_at)}</td>
|
| 593 |
+
<td>${esc(r.slot||'')}</td>
|
| 594 |
+
<td><span class="pill ${r.ok?'pill-ok':'pill-bad'}">${r.ok?'Passed':'Failed'}</span></td>
|
| 595 |
+
<td>${r.latency_ms!=null?(r.latency_ms/1000).toFixed(1)+'s':''}</td>
|
| 596 |
+
<td class="mono">${esc(r.error || r.detail || '')}</td>
|
| 597 |
+
</tr>`).join('') + `</tbody></table>`
|
| 598 |
+
: '');
|
| 599 |
+
} catch(e) {
|
| 600 |
+
el.innerHTML = '<div class="err">Could not load health checks: '+esc(e.message)+'</div>';
|
| 601 |
+
}
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
// ββ Status βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 605 |
+
function yesNo(v){ return `<span class="pill ${v?'pill-ok':'pill-warn'}">${v?'Yes':'No'}</span>`; }
|
| 606 |
+
|
| 607 |
+
async function loadStatus(){
|
| 608 |
+
const el = document.getElementById('status');
|
| 609 |
+
try {
|
| 610 |
+
const d = await get('/admin/status');
|
| 611 |
+
const h = d.health || {};
|
| 612 |
+
const src = h.sources || {};
|
| 613 |
+
const k = src.khazna || {};
|
| 614 |
+
const hrs = Math.floor((d.uptime_seconds||0)/3600);
|
| 615 |
+
const mins = Math.floor(((d.uptime_seconds||0)%3600)/60);
|
| 616 |
+
el.innerHTML = `<table><tbody>
|
| 617 |
+
<tr><td>Version</td><td>${esc(h.version||'unknown')}</td></tr>
|
| 618 |
+
<tr><td>Running for</td><td>${hrs}h ${mins}m</td></tr>
|
| 619 |
+
<tr><td>Khazna index loaded</td><td>${yesNo(k.available)}${k.records?' · '+num(k.records)+' records':''}</td></tr>
|
| 620 |
+
<tr><td>KU institution ID set for permissions</td><td>${yesNo(src.oaworks && src.oaworks.ku_ror_set)}</td></tr>
|
| 621 |
+
<tr><td>Repository registry</td><td>${num((src.repository_registry||{}).entries)} entries</td></tr>
|
| 622 |
+
<tr><td>Analytics Worker configured</td><td>${yesNo(d.worker_url_configured)}</td></tr>
|
| 623 |
+
<tr><td>Worker token configured</td><td>${yesNo(d.worker_token_configured)}</td></tr>
|
| 624 |
+
<tr><td>Language model key set</td><td>${yesNo(d.openai_key_set)}</td></tr>
|
| 625 |
+
</tbody></table>`;
|
| 626 |
+
} catch(e) {
|
| 627 |
+
el.innerHTML = '<div class="err">Could not load status: '+esc(e.message)+'</div>';
|
| 628 |
+
}
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
// ββ Actions ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 632 |
+
document.getElementById('reload-khazna').addEventListener('click', async (ev) => {
|
| 633 |
+
const out = document.getElementById('action-out');
|
| 634 |
+
ev.target.disabled = true;
|
| 635 |
+
out.innerHTML = '<div class="msg">Reloadingβ¦</div>';
|
| 636 |
+
try {
|
| 637 |
+
const r = await fetch('/admin/reload-khazna', {method:'POST'});
|
| 638 |
+
const d = await r.json();
|
| 639 |
+
out.innerHTML = d.reloaded
|
| 640 |
+
? `<div class="msg">Reloaded. ${num((d.khazna||{}).records)} records now in the index.</div>`
|
| 641 |
+
: `<div class="err">Reload failed. Check the Space logs for the line beginning "Khazna index download failed".</div>`;
|
| 642 |
+
loadStatus();
|
| 643 |
+
} catch(e) {
|
| 644 |
+
out.innerHTML = '<div class="err">Reload failed: '+esc(e.message)+'</div>';
|
| 645 |
+
}
|
| 646 |
+
ev.target.disabled = false;
|
| 647 |
+
});
|
| 648 |
+
|
| 649 |
+
document.getElementById('show-local').addEventListener('click', async () => {
|
| 650 |
+
const out = document.getElementById('action-out');
|
| 651 |
+
try {
|
| 652 |
+
const d = await get('/admin/local-usage');
|
| 653 |
+
const rows = Object.entries(d.by_surface||{});
|
| 654 |
+
out.innerHTML =
|
| 655 |
+
`<div class="msg">${esc(d.note)} Counting since ${esc(d.since)}. `+
|
| 656 |
+
`${d.pending_deltas} delta(s) waiting to be sent.</div>` +
|
| 657 |
+
(rows.length
|
| 658 |
+
? `<table><thead><tr><th>Tool</th><th>Used</th><th>Unanswered</th><th>Errors</th></tr></thead><tbody>` +
|
| 659 |
+
rows.map(([k,v]) => `<tr><td>${esc(SURFACE[k]||k)}</td><td>${num(v.total)}</td><td>${num(v.miss)}</td><td>${num(v.error)}</td></tr>`).join('') +
|
| 660 |
+
`</tbody></table>`
|
| 661 |
+
: '<div class="msg">Nothing counted since this container started.</div>') +
|
| 662 |
+
((d.recent_flushes||[]).length
|
| 663 |
+
? `<div class="hint" style="margin-top:10px">Last send to the Worker: <span class="mono">${esc(d.recent_flushes[0].outcome)} β ${esc(d.recent_flushes[0].detail)}</span></div>`
|
| 664 |
+
: '');
|
| 665 |
+
} catch(e) {
|
| 666 |
+
out.innerHTML = '<div class="err">'+esc(e.message)+'</div>';
|
| 667 |
+
}
|
| 668 |
+
});
|
| 669 |
+
|
| 670 |
+
// This Space cannot reach Cloudflare, but your browser can β the same route
|
| 671 |
+
// the dashboard reads through. So a manual send goes: Space -> browser -> Worker.
|
| 672 |
+
// Worth doing before a planned redeploy, since counts sit in memory until the
|
| 673 |
+
// Worker's next 6-hourly pull.
|
| 674 |
+
document.getElementById('send-now').addEventListener('click', async (ev) => {
|
| 675 |
+
const out = document.getElementById('action-out');
|
| 676 |
+
ev.target.disabled = true;
|
| 677 |
+
out.innerHTML = '<div class="msg">Collecting countsβ¦</div>';
|
| 678 |
+
try {
|
| 679 |
+
const snap = await get('/admin/usage-export');
|
| 680 |
+
if (!snap.snapshot_id || !snap.deltas.length) {
|
| 681 |
+
out.innerHTML = '<div class="msg">Nothing waiting to be sent.</div>';
|
| 682 |
+
ev.target.disabled = false; return;
|
| 683 |
+
}
|
| 684 |
+
out.innerHTML = `<div class="msg">Sending ${snap.deltas.length} item(s) via this browserβ¦</div>`;
|
| 685 |
+
const r = await fetch(WORKER_URL + '/log', {
|
| 686 |
+
method: 'POST',
|
| 687 |
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + WORKER_TOKEN },
|
| 688 |
+
body: JSON.stringify({ deltas: snap.deltas })
|
| 689 |
+
});
|
| 690 |
+
const d = await r.json();
|
| 691 |
+
if (r.ok && d.logged) {
|
| 692 |
+
await fetch('/admin/usage-ack?snapshot_id=' + encodeURIComponent(snap.snapshot_id), {method:'POST'});
|
| 693 |
+
out.innerHTML = `<div class="msg">Sent. ${d.accepted} item(s) stored in Cloudflare.</div>`;
|
| 694 |
+
loadUsage();
|
| 695 |
+
} else {
|
| 696 |
+
out.innerHTML = `<div class="err">Cloudflare refused the counts: ${esc(d.warning||('HTTP '+r.status))}.
|
| 697 |
+
They are held and will be collected on the next scheduled pull.</div>`;
|
| 698 |
+
}
|
| 699 |
+
} catch(e) {
|
| 700 |
+
out.innerHTML = `<div class="err">Could not send: ${esc(e.message)}.
|
| 701 |
+
Counts are held and will be collected on the next scheduled pull.</div>`;
|
| 702 |
+
}
|
| 703 |
+
ev.target.disabled = false;
|
| 704 |
+
});
|
| 705 |
+
|
| 706 |
+
document.getElementById('probe-worker').addEventListener('click', async (ev) => {
|
| 707 |
+
const out = document.getElementById('action-out');
|
| 708 |
+
ev.target.disabled = true;
|
| 709 |
+
out.innerHTML = '<div class="msg">Testing three timesβ¦</div>';
|
| 710 |
+
try {
|
| 711 |
+
const d = await get('/admin/worker-probe');
|
| 712 |
+
const rows = (d.attempts||[]).map(a =>
|
| 713 |
+
`<tr><td>Attempt ${a.attempt}</td>
|
| 714 |
+
<td><span class="pill ${a.ok?'pill-ok':'pill-bad'}">${a.ok?'Reached':'Failed'}</span></td>
|
| 715 |
+
<td>${a.ms} ms</td><td class="mono">${esc(a.error||'')}</td></tr>`).join('');
|
| 716 |
+
out.innerHTML =
|
| 717 |
+
`<div class="msg">Verdict: <strong>${esc(d.verdict)}</strong></div>` +
|
| 718 |
+
`<table><tbody>${rows}</tbody></table>` +
|
| 719 |
+
`<div class="hint" style="margin-top:8px">${esc(d.note||'')}</div>`;
|
| 720 |
+
} catch(e) {
|
| 721 |
+
out.innerHTML = '<div class="err">'+esc(e.message)+'</div>';
|
| 722 |
+
}
|
| 723 |
+
ev.target.disabled = false;
|
| 724 |
+
});
|
| 725 |
+
|
| 726 |
+
loadUsage(); loadCanary(); loadStatus();
|
| 727 |
+
</script></body></html>"""
|
| 728 |
+
return HTMLResponse(
|
| 729 |
+
page.replace("__WORKER_URL__", WORKER_URL)
|
| 730 |
+
.replace("__WORKER_TOKEN__", WORKER_TOKEN)
|
| 731 |
)
|
app.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
ResearchBee β FastAPI backend v1.
|
| 3 |
|
| 4 |
Changes from v1.3 (verified-sources release):
|
| 5 |
- /api/check-license is now DOI-first and GROUNDED. Policy facts come from the
|
|
@@ -23,6 +23,7 @@ import json
|
|
| 23 |
import re
|
| 24 |
import asyncio
|
| 25 |
import logging
|
|
|
|
| 26 |
import time
|
| 27 |
from collections import defaultdict
|
| 28 |
from contextlib import asynccontextmanager
|
|
@@ -217,6 +218,34 @@ def get_client_ip(request: Request) -> str:
|
|
| 217 |
return forwarded.split(",")[0].strip()
|
| 218 |
return request.client.host if request.client else "unknown"
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
OPF_BASE = "https://v2.sherpa.ac.uk/cgi/retrieve" # Open Policy Finder (Jisc)
|
| 221 |
|
| 222 |
|
|
@@ -447,7 +476,7 @@ async def lifespan(app: FastAPI):
|
|
| 447 |
|
| 448 |
|
| 449 |
# ββ App ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 450 |
-
app = FastAPI(title="ResearchBee API", version="1.
|
| 451 |
|
| 452 |
app.add_middleware(
|
| 453 |
CORSMiddleware,
|
|
@@ -465,7 +494,7 @@ app.add_middleware(
|
|
| 465 |
"http://127.0.0.1:3000",
|
| 466 |
],
|
| 467 |
allow_methods=["GET", "POST", "OPTIONS"],
|
| 468 |
-
allow_headers=["Content-Type"],
|
| 469 |
)
|
| 470 |
|
| 471 |
# Registered AFTER the CORS middleware on purpose. Starlette runs middleware
|
|
@@ -669,6 +698,106 @@ def enrich_extended(extended: list) -> list:
|
|
| 669 |
result.append(j)
|
| 670 |
return result
|
| 671 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 672 |
|
| 673 |
# ββ OpenAI call with retry βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 674 |
@retry(
|
|
@@ -887,7 +1016,7 @@ async def health():
|
|
| 887 |
return {
|
| 888 |
"status": "ok",
|
| 889 |
"service": "ResearchBee",
|
| 890 |
-
"version": "1.
|
| 891 |
# Restart detection for the uptime monitor. boot_ts is the stable
|
| 892 |
# identity of this process; when it changes between two polls the
|
| 893 |
# container was replaced, whatever the HTTP status said.
|
|
@@ -919,6 +1048,7 @@ async def reload_khazna(request: Request):
|
|
| 919 |
picks up new data within seconds instead of waiting for the 23:30 refresh.
|
| 920 |
Idempotent and harmless: it only re-reads a public dataset.
|
| 921 |
"""
|
|
|
|
| 922 |
ip = get_client_ip(request)
|
| 923 |
if not check_rate_limit(ip, "global"):
|
| 924 |
raise HTTPException(429, "Too many requests.")
|
|
@@ -976,6 +1106,52 @@ async def openalex_works(request: Request, source_id: str, per_page: int = 5):
|
|
| 976 |
return {"works": []}
|
| 977 |
|
| 978 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 979 |
# ββ Trending papers by concept/topic βββββββββββββββββββββββββββββββββββββ
|
| 980 |
@app.get("/api/trending-papers")
|
| 981 |
async def trending_papers(request: Request, concept: str = "", concept_id: str = "", per_page: int = 5):
|
|
@@ -1105,6 +1281,10 @@ async def analyze_journal(req: JournalRequest, request: Request):
|
|
| 1105 |
if "error" in parsed:
|
| 1106 |
raise HTTPException(500, parsed["error"])
|
| 1107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1108 |
if isinstance(parsed.get("journals"), list):
|
| 1109 |
parsed["journals"] = await enrich_journals(parsed["journals"])
|
| 1110 |
if isinstance(parsed.get("extended_list"), list):
|
|
@@ -1166,12 +1346,14 @@ def khazna_status_for(doi: str) -> dict:
|
|
| 1166 |
async def _advisory_note(green: dict, li: "LicenseInput", lang: str) -> str:
|
| 1167 |
"""The LLM writes prose from verified facts only. It never supplies a value."""
|
| 1168 |
facts = {
|
|
|
|
|
|
|
| 1169 |
"can_archive": green.get("_can_archive"),
|
| 1170 |
"versions_permitted": green.get("_versions"),
|
| 1171 |
"best_version": green.get("_best_version"),
|
| 1172 |
"locations": green.get("_locations"),
|
| 1173 |
"embargo_months": green.get("_embargo_months"),
|
| 1174 |
-
"embargo_stated": green.get("
|
| 1175 |
"embargo_end": green.get("embargo_end"),
|
| 1176 |
"licence": green.get("_licence"),
|
| 1177 |
"journal_oa_type": green.get("journal_oa_type"),
|
|
@@ -1262,48 +1444,41 @@ async def check_license(req: LicenseRequest, request: Request):
|
|
| 1262 |
if not li.journal_name:
|
| 1263 |
raise HTTPException(422, "Provide either an article DOI/URL or a journal name.")
|
| 1264 |
|
| 1265 |
-
client = get_client()
|
| 1266 |
issn = doi_utils.normalise_issn(li.issn) or li.issn
|
| 1267 |
-
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
|
| 1271 |
-
|
| 1272 |
-
|
| 1273 |
-
|
|
|
|
| 1274 |
)
|
| 1275 |
-
parsed = await call_openai(client, LICENSE_SYSTEM_PROMPT, user_msg,
|
| 1276 |
-
max_tokens=1200, model=MODEL_LIGHT)
|
| 1277 |
-
if "error" in parsed:
|
| 1278 |
-
raise HTTPException(500, parsed["error"])
|
| 1279 |
-
|
| 1280 |
-
# HARD GUARD: the model has no database, so it may never claim confirmation.
|
| 1281 |
-
verify = "https://openpolicyfinder.jisc.ac.uk/" + (f"search?term={issn}" if issn else "")
|
| 1282 |
-
for j_ in (parsed.get("journals") or []):
|
| 1283 |
-
g = j_.setdefault("green_oa", {})
|
| 1284 |
-
g["policy_status"] = "Not confirmed"
|
| 1285 |
-
g["risk_flag"] = ("Journal-level estimate only β not verified against a policy "
|
| 1286 |
-
"database. Enter your article's DOI for a confirmed answer.")
|
| 1287 |
-
g["evidence_note"] = ("No verified permission record was retrieved. "
|
| 1288 |
-
f"Verify at {verify}")
|
| 1289 |
-
g["_verify_url"] = verify
|
| 1290 |
-
if not j_.get("issn"):
|
| 1291 |
-
j_["issn"] = issn
|
| 1292 |
|
| 1293 |
-
|
| 1294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1295 |
|
| 1296 |
-
rec = parsed.setdefault("repository_recommendation", {})
|
| 1297 |
-
rec["manual_checks_required"] = (rec.get("manual_checks_required") or []) + [
|
| 1298 |
-
"Verify the policy at Open Policy Finder before depositing."]
|
| 1299 |
-
parsed["khazna_status"] = {"checked": False, "reason": "no DOI supplied"}
|
| 1300 |
# Path B never produces a verified answer. Counting it as a miss keeps the
|
| 1301 |
-
# dashboard honest about how often people arrive without a DOI
|
| 1302 |
-
# number is high, the form needs to ask for one more insistently.
|
| 1303 |
usage.usage.record_gap("licence_deposit", "khazna_no_doi")
|
| 1304 |
request.state.usage_recorded = True
|
| 1305 |
-
parsed["source_mode"] = "journal"
|
| 1306 |
-
parsed["khazna"] = KHAZNA_DEPOSIT_CARD
|
| 1307 |
return {"result": parsed}
|
| 1308 |
|
| 1309 |
|
|
@@ -1328,6 +1503,9 @@ async def find_repository(req: RepoRequest, request: Request):
|
|
| 1328 |
# The model selected ids and wrote fit_reasons; the SERVER supplies every
|
| 1329 |
# factual field. Unknown ids are silently dropped.
|
| 1330 |
repos = hydrate_selection(parsed.get("repositories", []))
|
|
|
|
|
|
|
|
|
|
| 1331 |
|
| 1332 |
# Khazna always leads, and is never a model choice.
|
| 1333 |
repos = [r for r in repos if not r.get("is_khazna")]
|
|
@@ -1347,6 +1525,7 @@ async def find_repository(req: RepoRequest, request: Request):
|
|
| 1347 |
}
|
| 1348 |
|
| 1349 |
parsed["repositories"] = repos
|
|
|
|
| 1350 |
parsed["registry_note"] = (
|
| 1351 |
"Repositories are selected from a registry curated by KU Library. "
|
| 1352 |
"Factual details are served from verified records, not generated."
|
|
@@ -1388,6 +1567,10 @@ def subject_prompt_with_vocabulary() -> str:
|
|
| 1388 |
|
| 1389 |
@app.post("/api/browse-subject")
|
| 1390 |
async def browse_subject(req: SubjectRequest, request: Request):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1391 |
# Guard: ensure SCImago index is loaded (cold-start protection)
|
| 1392 |
from scimago_index import _subject_cache, _scimago_cache
|
| 1393 |
if not _subject_cache or not _scimago_cache:
|
|
@@ -1528,24 +1711,9 @@ async def browse_subject(req: SubjectRequest, request: Request):
|
|
| 1528 |
"journals": [],
|
| 1529 |
}}
|
| 1530 |
|
| 1531 |
-
|
| 1532 |
-
|
| 1533 |
-
|
| 1534 |
-
sourceid = j.get("sourceid", "")
|
| 1535 |
-
vl = build_verify_links(j["title"], issn, sourceid)
|
| 1536 |
-
q = j.get("quartile", "")
|
| 1537 |
-
enriched.append({
|
| 1538 |
-
"name": j["title"],
|
| 1539 |
-
"issn": issn or "",
|
| 1540 |
-
"quartile": q if q and q != "-" else None,
|
| 1541 |
-
"h_index": j.get("h_index"),
|
| 1542 |
-
"publisher": j.get("publisher", ""),
|
| 1543 |
-
"open_access": j.get("open_access", "No"),
|
| 1544 |
-
"country": j.get("country", ""),
|
| 1545 |
-
"categories": j.get("categories", ""),
|
| 1546 |
-
"verify_links": vl,
|
| 1547 |
-
"ku_apc_covered": is_ku_apc_covered(j.get("publisher", ""), j.get("quartile", "")),
|
| 1548 |
-
})
|
| 1549 |
|
| 1550 |
msg = user_message
|
| 1551 |
if fallback_used:
|
|
|
|
| 1 |
"""
|
| 2 |
+
ResearchBee β FastAPI backend v1.5.0 (Release A correctness and safety gate)
|
| 3 |
|
| 4 |
Changes from v1.3 (verified-sources release):
|
| 5 |
- /api/check-license is now DOI-first and GROUNDED. Policy facts come from the
|
|
|
|
| 23 |
import re
|
| 24 |
import asyncio
|
| 25 |
import logging
|
| 26 |
+
import secrets
|
| 27 |
import time
|
| 28 |
from collections import defaultdict
|
| 29 |
from contextlib import asynccontextmanager
|
|
|
|
| 218 |
return forwarded.split(",")[0].strip()
|
| 219 |
return request.client.host if request.client else "unknown"
|
| 220 |
|
| 221 |
+
|
| 222 |
+
KHAZNA_RELOAD_HEADER = "X-ResearchBee-Reload-Token"
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def require_khazna_reload_auth(request: Request) -> None:
|
| 226 |
+
"""Require the Space secret before allowing a Khazna index reload."""
|
| 227 |
+
expected = os.environ.get("KHAZNA_RELOAD_TOKEN", "").strip()
|
| 228 |
+
if not expected:
|
| 229 |
+
raise HTTPException(
|
| 230 |
+
503,
|
| 231 |
+
"Khazna reload authentication is not configured.",
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
supplied = request.headers.get(KHAZNA_RELOAD_HEADER, "").strip()
|
| 235 |
+
if not supplied:
|
| 236 |
+
auth = request.headers.get("Authorization", "").strip()
|
| 237 |
+
if auth.lower().startswith("bearer "):
|
| 238 |
+
supplied = auth[7:].strip()
|
| 239 |
+
|
| 240 |
+
if not supplied:
|
| 241 |
+
raise HTTPException(
|
| 242 |
+
401,
|
| 243 |
+
"Khazna reload token required.",
|
| 244 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 245 |
+
)
|
| 246 |
+
if not secrets.compare_digest(supplied, expected):
|
| 247 |
+
raise HTTPException(403, "Invalid Khazna reload token.")
|
| 248 |
+
|
| 249 |
OPF_BASE = "https://v2.sherpa.ac.uk/cgi/retrieve" # Open Policy Finder (Jisc)
|
| 250 |
|
| 251 |
|
|
|
|
| 476 |
|
| 477 |
|
| 478 |
# ββ App ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 479 |
+
app = FastAPI(title="ResearchBee API", version="1.5.0", lifespan=lifespan)
|
| 480 |
|
| 481 |
app.add_middleware(
|
| 482 |
CORSMiddleware,
|
|
|
|
| 494 |
"http://127.0.0.1:3000",
|
| 495 |
],
|
| 496 |
allow_methods=["GET", "POST", "OPTIONS"],
|
| 497 |
+
allow_headers=["Content-Type", "Authorization", "X-ResearchBee-Reload-Token"],
|
| 498 |
)
|
| 499 |
|
| 500 |
# Registered AFTER the CORS middleware on purpose. Starlette runs middleware
|
|
|
|
| 698 |
result.append(j)
|
| 699 |
return result
|
| 700 |
|
| 701 |
+
_POLICY_ACTION_RE = re.compile(
|
| 702 |
+
r"(?:self[- ]?archiv|embargo|deposit|repository|licen[cs]e|preprint|postprint|"
|
| 703 |
+
r"accepted manuscript|version of record|green oa|\baam\b)",
|
| 704 |
+
re.IGNORECASE,
|
| 705 |
+
)
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
def enforce_journal_policy_guard(parsed: dict) -> dict:
|
| 709 |
+
"""
|
| 710 |
+
Make the journal-finder workflow incapable of returning LLM policy facts.
|
| 711 |
+
|
| 712 |
+
The model may rank and explain journals, but every policy-bearing field is
|
| 713 |
+
replaced with a canonical, three-valued "unknown" response. This guard is
|
| 714 |
+
deliberately server-side so a future prompt regression cannot reintroduce
|
| 715 |
+
apparent permissions, embargoes, or licences.
|
| 716 |
+
"""
|
| 717 |
+
if not isinstance(parsed, dict):
|
| 718 |
+
return {}
|
| 719 |
+
|
| 720 |
+
for journal in parsed.get("journals") or []:
|
| 721 |
+
if not isinstance(journal, dict):
|
| 722 |
+
continue
|
| 723 |
+
green = oaworks.not_confirmed(
|
| 724 |
+
journal.get("name", ""),
|
| 725 |
+
journal.get("issn", ""),
|
| 726 |
+
reason="Journal Finder evaluates fit only; no article-level permission lookup was performed",
|
| 727 |
+
)
|
| 728 |
+
green["repository_action_note"] = (
|
| 729 |
+
"Use ResearchBee License Checking with the article DOI before depositing any manuscript version."
|
| 730 |
+
)
|
| 731 |
+
green["evidence_note"] = (
|
| 732 |
+
"No article-level permission lookup is performed by Journal Finder. "
|
| 733 |
+
"Policy values from the language model are discarded by the server."
|
| 734 |
+
)
|
| 735 |
+
green["risk_flag"] = "Deposit rights not checked β verify with the article DOI."
|
| 736 |
+
journal["green_oa"] = green
|
| 737 |
+
journal["oa_compliance_note"] = (
|
| 738 |
+
"Journal Finder does not determine self-archiving rights. Use the DOI-based "
|
| 739 |
+
"License Checking tool before depositing any version."
|
| 740 |
+
)
|
| 741 |
+
|
| 742 |
+
unknown = oaworks.not_confirmed(
|
| 743 |
+
reason="Journal Finder does not perform an article-level permission lookup"
|
| 744 |
+
)
|
| 745 |
+
parsed["repository_recommendation"] = oaworks.recommendation_from(unknown)
|
| 746 |
+
|
| 747 |
+
safe_actions = []
|
| 748 |
+
for action in parsed.get("next_actions") or []:
|
| 749 |
+
action = " ".join(str(action or "").split())
|
| 750 |
+
if action and not _POLICY_ACTION_RE.search(action):
|
| 751 |
+
safe_actions.append(action[:600])
|
| 752 |
+
safe_actions.extend([
|
| 753 |
+
"Verify the journal scope, author guidelines, current fees, and indexation before submission.",
|
| 754 |
+
"Use ResearchBee License Checking with the article DOI before depositing any manuscript version.",
|
| 755 |
+
])
|
| 756 |
+
# Stable de-duplication.
|
| 757 |
+
parsed["next_actions"] = list(dict.fromkeys(safe_actions))
|
| 758 |
+
parsed["global_notes"] = (
|
| 759 |
+
"Journal Finder evaluates publication fit and verified journal metrics only. "
|
| 760 |
+
"Self-archiving permissions, embargoes, and licences require a DOI-based check."
|
| 761 |
+
)
|
| 762 |
+
parsed["policy_source_mode"] = "not_checked_in_journal_finder"
|
| 763 |
+
return parsed
|
| 764 |
+
|
| 765 |
+
|
| 766 |
+
def build_subject_journal_result(journal: dict) -> dict:
|
| 767 |
+
"""Build one subject-browse row, including all DB-backed field quartiles."""
|
| 768 |
+
issn = journal.get("issn")
|
| 769 |
+
scimago = lookup_any([issn]) if issn else None
|
| 770 |
+
if not scimago:
|
| 771 |
+
scimago = _scimago_title_fallback(journal.get("title", ""))
|
| 772 |
+
|
| 773 |
+
source = scimago or journal
|
| 774 |
+
final_issn = source.get("matched_issn") or issn or ""
|
| 775 |
+
quartile = source.get("quartile") or journal.get("quartile", "")
|
| 776 |
+
h_index = source.get("h_index", journal.get("h_index"))
|
| 777 |
+
try:
|
| 778 |
+
h_index = int(h_index) if h_index not in (None, "") else None
|
| 779 |
+
except (TypeError, ValueError):
|
| 780 |
+
h_index = None
|
| 781 |
+
|
| 782 |
+
result = {
|
| 783 |
+
"name": journal.get("title", ""),
|
| 784 |
+
"issn": final_issn,
|
| 785 |
+
"quartile": quartile if quartile and quartile != "-" else None,
|
| 786 |
+
"h_index": h_index,
|
| 787 |
+
"publisher": source.get("publisher", "") or journal.get("publisher", ""),
|
| 788 |
+
"open_access": source.get("open_access", journal.get("open_access", "No")),
|
| 789 |
+
"country": source.get("country", journal.get("country", "")),
|
| 790 |
+
"categories": source.get("categories", journal.get("categories", "")),
|
| 791 |
+
"verify_links": build_verify_links(
|
| 792 |
+
journal.get("title", ""),
|
| 793 |
+
final_issn,
|
| 794 |
+
source.get("sourceid", journal.get("sourceid", "")),
|
| 795 |
+
),
|
| 796 |
+
}
|
| 797 |
+
result.update(_index_extras(scimago))
|
| 798 |
+
result["ku_apc_covered"] = is_ku_apc_covered(result["publisher"], quartile)
|
| 799 |
+
return result
|
| 800 |
+
|
| 801 |
|
| 802 |
# ββ OpenAI call with retry βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 803 |
@retry(
|
|
|
|
| 1016 |
return {
|
| 1017 |
"status": "ok",
|
| 1018 |
"service": "ResearchBee",
|
| 1019 |
+
"version": "1.5.0",
|
| 1020 |
# Restart detection for the uptime monitor. boot_ts is the stable
|
| 1021 |
# identity of this process; when it changes between two polls the
|
| 1022 |
# container was replaced, whatever the HTTP status said.
|
|
|
|
| 1048 |
picks up new data within seconds instead of waiting for the 23:30 refresh.
|
| 1049 |
Idempotent and harmless: it only re-reads a public dataset.
|
| 1050 |
"""
|
| 1051 |
+
require_khazna_reload_auth(request)
|
| 1052 |
ip = get_client_ip(request)
|
| 1053 |
if not check_rate_limit(ip, "global"):
|
| 1054 |
raise HTTPException(429, "Too many requests.")
|
|
|
|
| 1106 |
return {"works": []}
|
| 1107 |
|
| 1108 |
|
| 1109 |
+
@app.get("/api/openalex-cited-by")
|
| 1110 |
+
async def openalex_cited_by(request: Request, work_id: str, per_page: int = 5):
|
| 1111 |
+
"""Return the most-cited papers that cite one validated OpenAlex work id."""
|
| 1112 |
+
ip = get_client_ip(request)
|
| 1113 |
+
if not check_rate_limit(ip, "global"):
|
| 1114 |
+
raise HTTPException(429, "Too many requests. Please wait a moment.")
|
| 1115 |
+
|
| 1116 |
+
clean_id = str(work_id or "").strip().replace("https://openalex.org/", "")
|
| 1117 |
+
if not re.fullmatch(r"W\d+", clean_id):
|
| 1118 |
+
raise HTTPException(400, "Invalid OpenAlex work_id")
|
| 1119 |
+
|
| 1120 |
+
try:
|
| 1121 |
+
client = openalex._get_client()
|
| 1122 |
+
response = await client.get(
|
| 1123 |
+
"https://api.openalex.org/works",
|
| 1124 |
+
params={
|
| 1125 |
+
"filter": f"cites:{clean_id},is_retracted:false",
|
| 1126 |
+
"sort": "cited_by_count:desc",
|
| 1127 |
+
"per_page": min(max(per_page, 1), 8),
|
| 1128 |
+
"select": "id,title,publication_year,doi,cited_by_count,authorships",
|
| 1129 |
+
},
|
| 1130 |
+
timeout=6.0,
|
| 1131 |
+
)
|
| 1132 |
+
if not response.is_success:
|
| 1133 |
+
return {"works": []}
|
| 1134 |
+
|
| 1135 |
+
works = []
|
| 1136 |
+
for work in response.json().get("results", []):
|
| 1137 |
+
authors = [
|
| 1138 |
+
authorship.get("author", {}).get("display_name", "")
|
| 1139 |
+
for authorship in (work.get("authorships") or [])[:3]
|
| 1140 |
+
]
|
| 1141 |
+
works.append({
|
| 1142 |
+
"title": work.get("title", ""),
|
| 1143 |
+
"year": work.get("publication_year"),
|
| 1144 |
+
"doi": work.get("doi", ""),
|
| 1145 |
+
"cited_by": work.get("cited_by_count", 0),
|
| 1146 |
+
"authors": [author for author in authors if author],
|
| 1147 |
+
"openalex_url": work.get("id", ""),
|
| 1148 |
+
})
|
| 1149 |
+
return {"works": works}
|
| 1150 |
+
except Exception as exc:
|
| 1151 |
+
logger.warning("[OpenAlex cited-by] %s", exc)
|
| 1152 |
+
return {"works": []}
|
| 1153 |
+
|
| 1154 |
+
|
| 1155 |
# ββ Trending papers by concept/topic βββββββββββββββββββββββββββββββββββββ
|
| 1156 |
@app.get("/api/trending-papers")
|
| 1157 |
async def trending_papers(request: Request, concept: str = "", concept_id: str = "", per_page: int = 5):
|
|
|
|
| 1281 |
if "error" in parsed:
|
| 1282 |
raise HTTPException(500, parsed["error"])
|
| 1283 |
|
| 1284 |
+
# Release A hard guard: the language model ranks journals but may never
|
| 1285 |
+
# provide self-archiving facts in this workflow, even if a prompt regresses.
|
| 1286 |
+
parsed = enforce_journal_policy_guard(parsed)
|
| 1287 |
+
|
| 1288 |
if isinstance(parsed.get("journals"), list):
|
| 1289 |
parsed["journals"] = await enrich_journals(parsed["journals"])
|
| 1290 |
if isinstance(parsed.get("extended_list"), list):
|
|
|
|
| 1346 |
async def _advisory_note(green: dict, li: "LicenseInput", lang: str) -> str:
|
| 1347 |
"""The LLM writes prose from verified facts only. It never supplies a value."""
|
| 1348 |
facts = {
|
| 1349 |
+
"permission_status": green.get("permission_status", "unknown"),
|
| 1350 |
+
"embargo_status": green.get("embargo_status", "unknown"),
|
| 1351 |
"can_archive": green.get("_can_archive"),
|
| 1352 |
"versions_permitted": green.get("_versions"),
|
| 1353 |
"best_version": green.get("_best_version"),
|
| 1354 |
"locations": green.get("_locations"),
|
| 1355 |
"embargo_months": green.get("_embargo_months"),
|
| 1356 |
+
"embargo_stated": green.get("embargo_status") != "unknown",
|
| 1357 |
"embargo_end": green.get("embargo_end"),
|
| 1358 |
"licence": green.get("_licence"),
|
| 1359 |
"journal_oa_type": green.get("journal_oa_type"),
|
|
|
|
| 1444 |
if not li.journal_name:
|
| 1445 |
raise HTTPException(422, "Provide either an article DOI/URL or a journal name.")
|
| 1446 |
|
|
|
|
| 1447 |
issn = doi_utils.normalise_issn(li.issn) or li.issn
|
| 1448 |
+
green = oaworks.not_confirmed(
|
| 1449 |
+
li.journal_name,
|
| 1450 |
+
issn,
|
| 1451 |
+
reason="no article DOI was supplied, so no article-level permission lookup was possible",
|
| 1452 |
+
)
|
| 1453 |
+
green["repository_action_note"] = (
|
| 1454 |
+
"Enter the article DOI for a verified permission result. Until then, do not infer "
|
| 1455 |
+
"a permitted version, embargo, licence, or deposit timing from the journal name alone."
|
| 1456 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1457 |
|
| 1458 |
+
journal = {
|
| 1459 |
+
"name": li.journal_name,
|
| 1460 |
+
"issn": issn,
|
| 1461 |
+
"publisher": li.publisher,
|
| 1462 |
+
"green_oa": green,
|
| 1463 |
+
}
|
| 1464 |
+
enriched = await enrich_journals([journal])
|
| 1465 |
+
parsed = {
|
| 1466 |
+
"journals": enriched,
|
| 1467 |
+
"repository_recommendation": oaworks.recommendation_from(green),
|
| 1468 |
+
"next_actions": oaworks.next_actions_from(green, ""),
|
| 1469 |
+
"global_notes": (
|
| 1470 |
+
"No DOI was supplied. ResearchBee therefore reports permission and embargo "
|
| 1471 |
+
"as unknown rather than estimating them from a journal-level model response."
|
| 1472 |
+
),
|
| 1473 |
+
"khazna_status": {"checked": False, "reason": "no DOI supplied"},
|
| 1474 |
+
"source_mode": "journal",
|
| 1475 |
+
"khazna": KHAZNA_DEPOSIT_CARD,
|
| 1476 |
+
}
|
| 1477 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1478 |
# Path B never produces a verified answer. Counting it as a miss keeps the
|
| 1479 |
+
# dashboard honest about how often people arrive without a DOI.
|
|
|
|
| 1480 |
usage.usage.record_gap("licence_deposit", "khazna_no_doi")
|
| 1481 |
request.state.usage_recorded = True
|
|
|
|
|
|
|
| 1482 |
return {"result": parsed}
|
| 1483 |
|
| 1484 |
|
|
|
|
| 1503 |
# The model selected ids and wrote fit_reasons; the SERVER supplies every
|
| 1504 |
# factual field. Unknown ids are silently dropped.
|
| 1505 |
repos = hydrate_selection(parsed.get("repositories", []))
|
| 1506 |
+
parsed["deposit_strategy"] = repositories.hydrate_deposit_strategy(
|
| 1507 |
+
parsed.get("deposit_strategy"), repos
|
| 1508 |
+
)
|
| 1509 |
|
| 1510 |
# Khazna always leads, and is never a model choice.
|
| 1511 |
repos = [r for r in repos if not r.get("is_khazna")]
|
|
|
|
| 1525 |
}
|
| 1526 |
|
| 1527 |
parsed["repositories"] = repos
|
| 1528 |
+
parsed["selection_schema_version"] = "release-a-id-only-v1"
|
| 1529 |
parsed["registry_note"] = (
|
| 1530 |
"Repositories are selected from a registry curated by KU Library. "
|
| 1531 |
"Factual details are served from verified records, not generated."
|
|
|
|
| 1567 |
|
| 1568 |
@app.post("/api/browse-subject")
|
| 1569 |
async def browse_subject(req: SubjectRequest, request: Request):
|
| 1570 |
+
ip = get_client_ip(request)
|
| 1571 |
+
if not check_rate_limit(ip, "global"):
|
| 1572 |
+
raise HTTPException(429, "Too many requests. Please wait a moment and try again.")
|
| 1573 |
+
|
| 1574 |
# Guard: ensure SCImago index is loaded (cold-start protection)
|
| 1575 |
from scimago_index import _subject_cache, _scimago_cache
|
| 1576 |
if not _subject_cache or not _scimago_cache:
|
|
|
|
| 1711 |
"journals": [],
|
| 1712 |
}}
|
| 1713 |
|
| 1714 |
+
# Enrich each subject result through journals.db so the response contains
|
| 1715 |
+
# every field-specific quartile, not only SCIMago's best headline quartile.
|
| 1716 |
+
enriched = [build_subject_journal_result(journal) for journal in journals]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1717 |
|
| 1718 |
msg = user_message
|
| 1719 |
if fallback_used:
|
asjc_classifier.py
CHANGED
|
@@ -1,216 +1,216 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Abstract -> ASJC subject codes, for populating ResearchBee's recommendation filter.
|
| 3 |
-
|
| 4 |
-
from asjc_classifier import AsjcClassifier
|
| 5 |
-
|
| 6 |
-
clf = AsjcClassifier("asjc_model.joblib") # local model only
|
| 7 |
-
clf = AsjcClassifier("asjc_model.joblib", llm=True) # + LLM adjudication
|
| 8 |
-
|
| 9 |
-
result = clf.classify(title=..., abstract=...)
|
| 10 |
-
result.codes # ['3309', '1710'] β feed straight to recommend(asjc=...)
|
| 11 |
-
result.method # 'linear+llm' | 'linear-topk'
|
| 12 |
-
result.candidates # what the LLM was asked to choose between
|
| 13 |
-
|
| 14 |
-
Architecture, and why it is not the obvious one
|
| 15 |
-
-----------------------------------------------
|
| 16 |
-
The obvious design is "run the linear model, escalate to an LLM when confidence
|
| 17 |
-
is low". Measured on the held-out abstract set, that design cannot work: the
|
| 18 |
-
top-1 probability averages 0.367 when the prediction is correct and 0.330 when
|
| 19 |
-
it is wrong. There is no threshold separating them β the model is wrong often
|
| 20 |
-
and unbothered about it.
|
| 21 |
-
|
| 22 |
-
What the model *is* good at is narrowing. A correct code appears within:
|
| 23 |
-
|
| 24 |
-
top-1 30%
|
| 25 |
-
top-3 70%
|
| 26 |
-
top-5 95% <- and flat thereafter
|
| 27 |
-
|
| 28 |
-
So it is used as a candidate generator, not a decider. The linear model cuts 334
|
| 29 |
-
codes to 5 with ~95% recall; the LLM picks among those 5. That inverts the usual
|
| 30 |
-
cost argument in our favour β the prompt carries five labelled options rather
|
| 31 |
-
than a 334-entry taxonomy, so the call is small, fast and hard to get wrong.
|
| 32 |
-
|
| 33 |
-
Degradation without an API key is graceful: `codes` becomes the top 3, which the
|
| 34 |
-
recommender applies as an OR filter. Recall is 70% rather than 95%, and the
|
| 35 |
-
candidate pool is broader, but nothing breaks.
|
| 36 |
-
|
| 37 |
-
Retrain after the OpenAlex harvest
|
| 38 |
-
----------------------------------
|
| 39 |
-
The shipped model is trained on journal titles, because that is the labelled text
|
| 40 |
-
available without a network. It is applied to abstracts. `harvest_openalex.py`
|
| 41 |
-
produces the matched corpus; retraining on it and re-running `asjc_eval.jsonl`
|
| 42 |
-
measures the gain on the same 20 abstracts, so the upgrade is quantified rather
|
| 43 |
-
than assumed.
|
| 44 |
-
"""
|
| 45 |
-
from __future__ import annotations
|
| 46 |
-
|
| 47 |
-
import json
|
| 48 |
-
import os
|
| 49 |
-
import re
|
| 50 |
-
from dataclasses import dataclass, field
|
| 51 |
-
from pathlib import Path
|
| 52 |
-
|
| 53 |
-
import joblib
|
| 54 |
-
import numpy as np
|
| 55 |
-
from scipy import sparse
|
| 56 |
-
|
| 57 |
-
__all__ = ["AsjcClassifier", "AsjcResult"]
|
| 58 |
-
|
| 59 |
-
#: Where the linear model's recall saturates. Measured, not guessed.
|
| 60 |
-
DEFAULT_K = 5
|
| 61 |
-
#: Codes returned when no LLM is available. top-3 = 70% recall.
|
| 62 |
-
FALLBACK_K = 3
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
@dataclass
|
| 66 |
-
class AsjcResult:
|
| 67 |
-
codes: list[str]
|
| 68 |
-
names: list[str]
|
| 69 |
-
method: str
|
| 70 |
-
candidates: list[dict] = field(default_factory=list)
|
| 71 |
-
llm_error: str | None = None
|
| 72 |
-
|
| 73 |
-
def __bool__(self) -> bool:
|
| 74 |
-
return bool(self.codes)
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
_PROMPT = """You are classifying an academic manuscript into Scopus ASJC subject areas.
|
| 78 |
-
|
| 79 |
-
A statistical model has narrowed 334 possible subject areas to the {n} candidates \
|
| 80 |
-
below. One or more of them is correct.
|
| 81 |
-
|
| 82 |
-
Candidates:
|
| 83 |
-
{candidates}
|
| 84 |
-
|
| 85 |
-
Manuscript title: {title}
|
| 86 |
-
|
| 87 |
-
Abstract: {abstract}
|
| 88 |
-
|
| 89 |
-
Choose the 1-3 candidates that best describe where this manuscript belongs, most \
|
| 90 |
-
appropriate first. Choose only from the candidate list β do not invent codes. If \
|
| 91 |
-
the manuscript is genuinely interdisciplinary, return more than one; if it clearly \
|
| 92 |
-
belongs to a single area, return just that one.
|
| 93 |
-
|
| 94 |
-
Respond with JSON only, no other text:
|
| 95 |
-
{{"codes": ["NNNN", ...], "reason": "one short sentence"}}"""
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
class AsjcClassifier:
|
| 99 |
-
def __init__(
|
| 100 |
-
self,
|
| 101 |
-
model_path: str | Path = "asjc_model.joblib",
|
| 102 |
-
llm: bool = False,
|
| 103 |
-
llm_model: str | None = None,
|
| 104 |
-
api_key: str | None = None,
|
| 105 |
-
):
|
| 106 |
-
self.bundle = joblib.load(model_path)
|
| 107 |
-
self.classes: np.ndarray = self.bundle["classes"]
|
| 108 |
-
self.names: dict[str, str] = self.bundle["names"]
|
| 109 |
-
self.parent_of: dict[str, str] = self.bundle["parent_of"]
|
| 110 |
-
self.trained_on: str = self.bundle.get("corpus", "unknown")
|
| 111 |
-
|
| 112 |
-
self._client = None
|
| 113 |
-
# Model id is configuration, not a constant β set ASJC_LLM_MODEL to
|
| 114 |
-
# whichever model you are entitled to. Verify it against your account's
|
| 115 |
-
# available models rather than trusting a default baked in here.
|
| 116 |
-
self.llm_model = llm_model or os.getenv("ASJC_LLM_MODEL", "claude-sonnet-4-5")
|
| 117 |
-
if llm:
|
| 118 |
-
key = api_key or os.getenv("ANTHROPIC_API_KEY")
|
| 119 |
-
if key:
|
| 120 |
-
try:
|
| 121 |
-
import anthropic
|
| 122 |
-
self._client = anthropic.Anthropic(api_key=key)
|
| 123 |
-
except ImportError:
|
| 124 |
-
pass # falls back to top-k; reported in AsjcResult.method
|
| 125 |
-
|
| 126 |
-
# -- stage 1: narrow ----------------------------------------------------
|
| 127 |
-
def candidates(self, text: str, k: int = DEFAULT_K) -> list[dict]:
|
| 128 |
-
"""The linear model's top-k, with names attached. ~95% recall at k=5."""
|
| 129 |
-
X = sparse.hstack([
|
| 130 |
-
self.bundle["word"].transform([text]),
|
| 131 |
-
self.bundle["char"].transform([text]),
|
| 132 |
-
]).tocsr()
|
| 133 |
-
p = self.bundle["clf"].predict_proba(X)[0]
|
| 134 |
-
order = np.argsort(-p)[:k]
|
| 135 |
-
return [
|
| 136 |
-
{
|
| 137 |
-
"code": str(self.classes[i]),
|
| 138 |
-
"name": self.names.get(str(self.classes[i]), str(self.classes[i])),
|
| 139 |
-
"score": round(float(p[i]), 4),
|
| 140 |
-
}
|
| 141 |
-
for i in order
|
| 142 |
-
]
|
| 143 |
-
|
| 144 |
-
# -- stage 2: adjudicate ------------------------------------------------
|
| 145 |
-
def classify(
|
| 146 |
-
self,
|
| 147 |
-
title: str = "",
|
| 148 |
-
abstract: str = "",
|
| 149 |
-
k: int = DEFAULT_K,
|
| 150 |
-
max_codes: int = 3,
|
| 151 |
-
) -> AsjcResult:
|
| 152 |
-
text = f"{title} {title} {abstract}".strip()
|
| 153 |
-
if not text:
|
| 154 |
-
return AsjcResult([], [], "empty")
|
| 155 |
-
|
| 156 |
-
cands = self.candidates(text, k=k)
|
| 157 |
-
if not cands:
|
| 158 |
-
return AsjcResult([], [], "no-candidates")
|
| 159 |
-
|
| 160 |
-
if self._client is None:
|
| 161 |
-
picked = [c["code"] for c in cands[:FALLBACK_K]]
|
| 162 |
-
return AsjcResult(picked, [self.names.get(c, c) for c in picked],
|
| 163 |
-
"linear-topk", cands)
|
| 164 |
-
|
| 165 |
-
listing = "\n".join(f" {c['code']} {c['name']}" for c in cands)
|
| 166 |
-
prompt = _PROMPT.format(
|
| 167 |
-
n=len(cands), candidates=listing,
|
| 168 |
-
title=title or "(not supplied)",
|
| 169 |
-
abstract=(abstract or "(not supplied)")[:4000],
|
| 170 |
-
)
|
| 171 |
-
try:
|
| 172 |
-
msg = self._client.messages.create(
|
| 173 |
-
model=self.llm_model,
|
| 174 |
-
max_tokens=300,
|
| 175 |
-
temperature=0,
|
| 176 |
-
messages=[{"role": "user", "content": prompt}],
|
| 177 |
-
)
|
| 178 |
-
raw = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
|
| 179 |
-
picked = self._parse(raw, {c["code"] for c in cands})[:max_codes]
|
| 180 |
-
if picked:
|
| 181 |
-
return AsjcResult(picked, [self.names.get(c, c) for c in picked],
|
| 182 |
-
"linear+llm", cands)
|
| 183 |
-
err = "LLM returned no valid code from the candidate set"
|
| 184 |
-
except Exception as e: # noqa: BLE001
|
| 185 |
-
err = f"{type(e).__name__}: {e}"
|
| 186 |
-
|
| 187 |
-
# Any LLM failure degrades to the statistical answer rather than raising.
|
| 188 |
-
# A broader-than-ideal subject filter is a far better outcome for the
|
| 189 |
-
# caller than a recommendation request that errors out.
|
| 190 |
-
picked = [c["code"] for c in cands[:FALLBACK_K]]
|
| 191 |
-
return AsjcResult(picked, [self.names.get(c, c) for c in picked],
|
| 192 |
-
"linear-topk", cands, llm_error=err)
|
| 193 |
-
|
| 194 |
-
@staticmethod
|
| 195 |
-
def _parse(raw: str, allowed: set[str]) -> list[str]:
|
| 196 |
-
"""
|
| 197 |
-
Pull codes out of the reply and hard-filter to the candidate set.
|
| 198 |
-
|
| 199 |
-
The filter is the point: it makes an invented or hallucinated code
|
| 200 |
-
structurally impossible to return, so the worst case is fewer codes
|
| 201 |
-
rather than a wrong one that silently poisons the journal filter.
|
| 202 |
-
"""
|
| 203 |
-
codes: list[str] = []
|
| 204 |
-
m = re.search(r"\{.*\}", raw, re.S)
|
| 205 |
-
if m:
|
| 206 |
-
try:
|
| 207 |
-
codes = [str(c).strip() for c in json.loads(m.group(0)).get("codes", [])]
|
| 208 |
-
except (json.JSONDecodeError, AttributeError, TypeError):
|
| 209 |
-
codes = []
|
| 210 |
-
if not codes:
|
| 211 |
-
codes = re.findall(r"\b\d{4}\b", raw)
|
| 212 |
-
out: list[str] = []
|
| 213 |
-
for c in codes:
|
| 214 |
-
if c in allowed and c not in out:
|
| 215 |
-
out.append(c)
|
| 216 |
-
return out
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Abstract -> ASJC subject codes, for populating ResearchBee's recommendation filter.
|
| 3 |
+
|
| 4 |
+
from asjc_classifier import AsjcClassifier
|
| 5 |
+
|
| 6 |
+
clf = AsjcClassifier("asjc_model.joblib") # local model only
|
| 7 |
+
clf = AsjcClassifier("asjc_model.joblib", llm=True) # + LLM adjudication
|
| 8 |
+
|
| 9 |
+
result = clf.classify(title=..., abstract=...)
|
| 10 |
+
result.codes # ['3309', '1710'] β feed straight to recommend(asjc=...)
|
| 11 |
+
result.method # 'linear+llm' | 'linear-topk'
|
| 12 |
+
result.candidates # what the LLM was asked to choose between
|
| 13 |
+
|
| 14 |
+
Architecture, and why it is not the obvious one
|
| 15 |
+
-----------------------------------------------
|
| 16 |
+
The obvious design is "run the linear model, escalate to an LLM when confidence
|
| 17 |
+
is low". Measured on the held-out abstract set, that design cannot work: the
|
| 18 |
+
top-1 probability averages 0.367 when the prediction is correct and 0.330 when
|
| 19 |
+
it is wrong. There is no threshold separating them β the model is wrong often
|
| 20 |
+
and unbothered about it.
|
| 21 |
+
|
| 22 |
+
What the model *is* good at is narrowing. A correct code appears within:
|
| 23 |
+
|
| 24 |
+
top-1 30%
|
| 25 |
+
top-3 70%
|
| 26 |
+
top-5 95% <- and flat thereafter
|
| 27 |
+
|
| 28 |
+
So it is used as a candidate generator, not a decider. The linear model cuts 334
|
| 29 |
+
codes to 5 with ~95% recall; the LLM picks among those 5. That inverts the usual
|
| 30 |
+
cost argument in our favour β the prompt carries five labelled options rather
|
| 31 |
+
than a 334-entry taxonomy, so the call is small, fast and hard to get wrong.
|
| 32 |
+
|
| 33 |
+
Degradation without an API key is graceful: `codes` becomes the top 3, which the
|
| 34 |
+
recommender applies as an OR filter. Recall is 70% rather than 95%, and the
|
| 35 |
+
candidate pool is broader, but nothing breaks.
|
| 36 |
+
|
| 37 |
+
Retrain after the OpenAlex harvest
|
| 38 |
+
----------------------------------
|
| 39 |
+
The shipped model is trained on journal titles, because that is the labelled text
|
| 40 |
+
available without a network. It is applied to abstracts. `harvest_openalex.py`
|
| 41 |
+
produces the matched corpus; retraining on it and re-running `asjc_eval.jsonl`
|
| 42 |
+
measures the gain on the same 20 abstracts, so the upgrade is quantified rather
|
| 43 |
+
than assumed.
|
| 44 |
+
"""
|
| 45 |
+
from __future__ import annotations
|
| 46 |
+
|
| 47 |
+
import json
|
| 48 |
+
import os
|
| 49 |
+
import re
|
| 50 |
+
from dataclasses import dataclass, field
|
| 51 |
+
from pathlib import Path
|
| 52 |
+
|
| 53 |
+
import joblib
|
| 54 |
+
import numpy as np
|
| 55 |
+
from scipy import sparse
|
| 56 |
+
|
| 57 |
+
__all__ = ["AsjcClassifier", "AsjcResult"]
|
| 58 |
+
|
| 59 |
+
#: Where the linear model's recall saturates. Measured, not guessed.
|
| 60 |
+
DEFAULT_K = 5
|
| 61 |
+
#: Codes returned when no LLM is available. top-3 = 70% recall.
|
| 62 |
+
FALLBACK_K = 3
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass
|
| 66 |
+
class AsjcResult:
|
| 67 |
+
codes: list[str]
|
| 68 |
+
names: list[str]
|
| 69 |
+
method: str
|
| 70 |
+
candidates: list[dict] = field(default_factory=list)
|
| 71 |
+
llm_error: str | None = None
|
| 72 |
+
|
| 73 |
+
def __bool__(self) -> bool:
|
| 74 |
+
return bool(self.codes)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
_PROMPT = """You are classifying an academic manuscript into Scopus ASJC subject areas.
|
| 78 |
+
|
| 79 |
+
A statistical model has narrowed 334 possible subject areas to the {n} candidates \
|
| 80 |
+
below. One or more of them is correct.
|
| 81 |
+
|
| 82 |
+
Candidates:
|
| 83 |
+
{candidates}
|
| 84 |
+
|
| 85 |
+
Manuscript title: {title}
|
| 86 |
+
|
| 87 |
+
Abstract: {abstract}
|
| 88 |
+
|
| 89 |
+
Choose the 1-3 candidates that best describe where this manuscript belongs, most \
|
| 90 |
+
appropriate first. Choose only from the candidate list β do not invent codes. If \
|
| 91 |
+
the manuscript is genuinely interdisciplinary, return more than one; if it clearly \
|
| 92 |
+
belongs to a single area, return just that one.
|
| 93 |
+
|
| 94 |
+
Respond with JSON only, no other text:
|
| 95 |
+
{{"codes": ["NNNN", ...], "reason": "one short sentence"}}"""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class AsjcClassifier:
|
| 99 |
+
def __init__(
|
| 100 |
+
self,
|
| 101 |
+
model_path: str | Path = "asjc_model.joblib",
|
| 102 |
+
llm: bool = False,
|
| 103 |
+
llm_model: str | None = None,
|
| 104 |
+
api_key: str | None = None,
|
| 105 |
+
):
|
| 106 |
+
self.bundle = joblib.load(model_path)
|
| 107 |
+
self.classes: np.ndarray = self.bundle["classes"]
|
| 108 |
+
self.names: dict[str, str] = self.bundle["names"]
|
| 109 |
+
self.parent_of: dict[str, str] = self.bundle["parent_of"]
|
| 110 |
+
self.trained_on: str = self.bundle.get("corpus", "unknown")
|
| 111 |
+
|
| 112 |
+
self._client = None
|
| 113 |
+
# Model id is configuration, not a constant β set ASJC_LLM_MODEL to
|
| 114 |
+
# whichever model you are entitled to. Verify it against your account's
|
| 115 |
+
# available models rather than trusting a default baked in here.
|
| 116 |
+
self.llm_model = llm_model or os.getenv("ASJC_LLM_MODEL", "claude-sonnet-4-5")
|
| 117 |
+
if llm:
|
| 118 |
+
key = api_key or os.getenv("ANTHROPIC_API_KEY")
|
| 119 |
+
if key:
|
| 120 |
+
try:
|
| 121 |
+
import anthropic
|
| 122 |
+
self._client = anthropic.Anthropic(api_key=key)
|
| 123 |
+
except ImportError:
|
| 124 |
+
pass # falls back to top-k; reported in AsjcResult.method
|
| 125 |
+
|
| 126 |
+
# -- stage 1: narrow ----------------------------------------------------
|
| 127 |
+
def candidates(self, text: str, k: int = DEFAULT_K) -> list[dict]:
|
| 128 |
+
"""The linear model's top-k, with names attached. ~95% recall at k=5."""
|
| 129 |
+
X = sparse.hstack([
|
| 130 |
+
self.bundle["word"].transform([text]),
|
| 131 |
+
self.bundle["char"].transform([text]),
|
| 132 |
+
]).tocsr()
|
| 133 |
+
p = self.bundle["clf"].predict_proba(X)[0]
|
| 134 |
+
order = np.argsort(-p)[:k]
|
| 135 |
+
return [
|
| 136 |
+
{
|
| 137 |
+
"code": str(self.classes[i]),
|
| 138 |
+
"name": self.names.get(str(self.classes[i]), str(self.classes[i])),
|
| 139 |
+
"score": round(float(p[i]), 4),
|
| 140 |
+
}
|
| 141 |
+
for i in order
|
| 142 |
+
]
|
| 143 |
+
|
| 144 |
+
# -- stage 2: adjudicate ------------------------------------------------
|
| 145 |
+
def classify(
|
| 146 |
+
self,
|
| 147 |
+
title: str = "",
|
| 148 |
+
abstract: str = "",
|
| 149 |
+
k: int = DEFAULT_K,
|
| 150 |
+
max_codes: int = 3,
|
| 151 |
+
) -> AsjcResult:
|
| 152 |
+
text = f"{title} {title} {abstract}".strip()
|
| 153 |
+
if not text:
|
| 154 |
+
return AsjcResult([], [], "empty")
|
| 155 |
+
|
| 156 |
+
cands = self.candidates(text, k=k)
|
| 157 |
+
if not cands:
|
| 158 |
+
return AsjcResult([], [], "no-candidates")
|
| 159 |
+
|
| 160 |
+
if self._client is None:
|
| 161 |
+
picked = [c["code"] for c in cands[:FALLBACK_K]]
|
| 162 |
+
return AsjcResult(picked, [self.names.get(c, c) for c in picked],
|
| 163 |
+
"linear-topk", cands)
|
| 164 |
+
|
| 165 |
+
listing = "\n".join(f" {c['code']} {c['name']}" for c in cands)
|
| 166 |
+
prompt = _PROMPT.format(
|
| 167 |
+
n=len(cands), candidates=listing,
|
| 168 |
+
title=title or "(not supplied)",
|
| 169 |
+
abstract=(abstract or "(not supplied)")[:4000],
|
| 170 |
+
)
|
| 171 |
+
try:
|
| 172 |
+
msg = self._client.messages.create(
|
| 173 |
+
model=self.llm_model,
|
| 174 |
+
max_tokens=300,
|
| 175 |
+
temperature=0,
|
| 176 |
+
messages=[{"role": "user", "content": prompt}],
|
| 177 |
+
)
|
| 178 |
+
raw = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
|
| 179 |
+
picked = self._parse(raw, {c["code"] for c in cands})[:max_codes]
|
| 180 |
+
if picked:
|
| 181 |
+
return AsjcResult(picked, [self.names.get(c, c) for c in picked],
|
| 182 |
+
"linear+llm", cands)
|
| 183 |
+
err = "LLM returned no valid code from the candidate set"
|
| 184 |
+
except Exception as e: # noqa: BLE001
|
| 185 |
+
err = f"{type(e).__name__}: {e}"
|
| 186 |
+
|
| 187 |
+
# Any LLM failure degrades to the statistical answer rather than raising.
|
| 188 |
+
# A broader-than-ideal subject filter is a far better outcome for the
|
| 189 |
+
# caller than a recommendation request that errors out.
|
| 190 |
+
picked = [c["code"] for c in cands[:FALLBACK_K]]
|
| 191 |
+
return AsjcResult(picked, [self.names.get(c, c) for c in picked],
|
| 192 |
+
"linear-topk", cands, llm_error=err)
|
| 193 |
+
|
| 194 |
+
@staticmethod
|
| 195 |
+
def _parse(raw: str, allowed: set[str]) -> list[str]:
|
| 196 |
+
"""
|
| 197 |
+
Pull codes out of the reply and hard-filter to the candidate set.
|
| 198 |
+
|
| 199 |
+
The filter is the point: it makes an invented or hallucinated code
|
| 200 |
+
structurally impossible to return, so the worst case is fewer codes
|
| 201 |
+
rather than a wrong one that silently poisons the journal filter.
|
| 202 |
+
"""
|
| 203 |
+
codes: list[str] = []
|
| 204 |
+
m = re.search(r"\{.*\}", raw, re.S)
|
| 205 |
+
if m:
|
| 206 |
+
try:
|
| 207 |
+
codes = [str(c).strip() for c in json.loads(m.group(0)).get("codes", [])]
|
| 208 |
+
except (json.JSONDecodeError, AttributeError, TypeError):
|
| 209 |
+
codes = []
|
| 210 |
+
if not codes:
|
| 211 |
+
codes = re.findall(r"\b\d{4}\b", raw)
|
| 212 |
+
out: list[str] = []
|
| 213 |
+
for c in codes:
|
| 214 |
+
if c in allowed and c not in out:
|
| 215 |
+
out.append(c)
|
| 216 |
+
return out
|
asjc_eval.jsonl
CHANGED
|
@@ -1,20 +1,20 @@
|
|
| 1 |
-
{"id": "lis-chatbot", "field": "Library & Information Science", "accept": ["3309", "1710", "1706"], "text": "We present a retrieval-augmented generation chatbot integrated with the Primo discovery layer at a university library. The system combines dense vector retrieval over catalogue records and LibGuides content with a large language model to answer patron reference questions at the point of need. We evaluate retrieval precision, answer groundedness and user satisfaction across 1,200 real reference queries submitted by academic library users over one semester."}
|
| 2 |
-
{"id": "bibliometrics", "field": "Scientometrics", "accept": ["3309", "1706", "3312"], "text": "This scientometric analysis maps the growth of research output across national research systems using bibliometric indicators derived from Scopus and Web of Science. We compute field-normalised citation impact, international collaboration rates and disciplinary specialisation indices for 42 countries between 2000 and 2024, and examine the relationship between public research funding intensity and scholarly productivity."}
|
| 3 |
-
{"id": "cardiology-rct", "field": "Cardiology", "accept": ["2705", "2700", "2736"], "text": "This multicentre randomised controlled trial evaluated the efficacy and safety of direct oral anticoagulation in patients with non-valvular atrial fibrillation. 840 patients were randomised to receive either apixaban or dose-adjusted warfarin, with a primary composite endpoint of ischaemic stroke or systemic embolism at 24 months. Cardiovascular mortality, major bleeding and all-cause hospitalisation were prespecified secondary outcomes."}
|
| 4 |
-
{"id": "perovskite", "field": "Materials / Energy", "accept": ["2504", "2500", "2508", "2102", "3104", "3107", "1600", "2200"], "text": "We report a perovskite solar cell architecture achieving 25.6% power conversion efficiency. Thin-film deposition of methylammonium lead iodide was optimised through antisolvent engineering and interfacial passivation, and long-term device stability under continuous one-sun illumination was characterised over 1000 hours using X-ray diffraction, photoluminescence spectroscopy and impedance analysis."}
|
| 5 |
-
{"id": "nlp-transformer", "field": "Computer Science / AI", "accept": ["1702", "1706", "1712", "1700"], "text": "We introduce a parameter-efficient fine-tuning method for large transformer language models that adapts fewer than 0.1% of weights while matching full fine-tuning performance on downstream natural language understanding benchmarks. We evaluate on GLUE and SuperGLUE, analyse the rank structure of the learned updates, and show consistent gains in low-resource transfer settings."}
|
| 6 |
-
{"id": "groundwater", "field": "Earth / Environmental Science", "accept": ["2312", "1908", "1909", "1900", "2300", "2305", "2215"], "text": "We assess groundwater recharge dynamics in an arid alluvial aquifer using a coupled unsaturated-zone and finite-difference flow model calibrated against a decade of piezometric observations. Stable isotope signatures and chloride mass balance were used to partition recharge sources, and abstraction scenarios were simulated to evaluate long-term aquifer sustainability under projected climate forcing."}
|
| 7 |
-
{"id": "nursing-education", "field": "Nursing", "accept": ["2900", "2922", "2901", "3304", "2908"], "text": "This mixed-methods study examined the effect of high-fidelity simulation on clinical reasoning among undergraduate nursing students. Ninety-four students completed simulated deteriorating-patient scenarios followed by structured debriefing. Clinical reasoning was assessed with a validated rubric, and semi-structured interviews explored students' experiences of psychological safety during debriefing."}
|
| 8 |
-
{"id": "corporate-finance", "field": "Economics & Finance", "accept": ["2003", "2002", "2000", "1402", "1403"], "text": "We examine the effect of corporate governance quality on the cost of debt financing using a panel of listed firms across 18 emerging markets. Employing instrumental-variable estimation to address endogeneity, we find that board independence and disclosure quality are associated with materially lower credit spreads, with the effect concentrated among firms facing greater information asymmetry."}
|
| 9 |
-
{"id": "medieval-history", "field": "History / Humanities", "accept": ["1202", "1200", "1208"], "text": "Drawing on previously unexamined chancery records, this article reconstructs patterns of monastic landholding in the late medieval Rhineland. It argues that the conventional narrative of continuous ecclesiastical accumulation obscures a sustained period of alienation driven by fiscal pressure, and situates these transactions within wider debates about the economic agency of religious houses."}
|
| 10 |
-
{"id": "sociolinguistics", "field": "Linguistics / Arts & Humanities", "accept": ["3310", "1203", "1200", "3316"], "text": "This study investigates code-switching practices among second-generation multilingual speakers in an urban migration context. Using a corpus of naturalistic conversational recordings, we analyse patterns of intra-sentential switching and examine how speakers deploy linguistic resources to index identity and negotiate group membership across community settings."}
|
| 11 |
-
{"id": "supply-chain", "field": "Operations / Decision Sciences", "accept": ["1803", "1800", "1802", "2209", "1408"], "text": "We formulate a stochastic mixed-integer programming model for resilient supply chain network design under correlated disruption risk. A Benders decomposition scheme with valid inequalities is developed to solve realistically sized instances, and computational experiments on a multi-echelon distribution case demonstrate substantial cost savings over deterministic and robust-optimisation baselines."}
|
| 12 |
-
{"id": "immunology", "field": "Immunology & Microbiology", "accept": ["2403", "2404", "2400", "2406", "1312"], "text": "We characterise the tissue-resident memory T cell compartment in the human respiratory mucosa following viral infection. Single-cell RNA sequencing combined with flow cytometry revealed transcriptionally distinct subsets with divergent cytokine profiles, and adoptive transfer experiments in a murine model demonstrated that these populations confer durable protection against heterologous challenge."}
|
| 13 |
-
{"id": "civil-engineering", "field": "Civil / Structural Engineering", "accept": ["2205", "2200", "2215", "2210"], "text": "This paper presents a performance-based seismic assessment of reinforced concrete moment frames designed to pre-1980 detailing provisions. Nonlinear time-history analyses were conducted across a suite of ground motions scaled to multiple hazard levels, and fragility surfaces were developed to quantify collapse probability and inform retrofit prioritisation for existing building stock."}
|
| 14 |
-
{"id": "education-policy", "field": "Education", "accept": ["3304", "3300", "3312"], "text": "We evaluate the impact of a national school autonomy reform on student attainment using a difference-in-differences design across administrative regions. Drawing on eight years of standardised assessment data linked to school-level governance records, we find heterogeneous effects that vary systematically with baseline school capacity and local labour market conditions."}
|
| 15 |
-
{"id": "pharmacology", "field": "Pharmacology & Pharmaceutics", "accept": ["3004", "3003", "3002", "3000", "1313"], "text": "We report the pharmacokinetic and pharmacodynamic characterisation of a novel small-molecule kinase inhibitor in preclinical models. Dose-ranging studies established plasma exposure and tissue distribution, hepatic microsomal assays quantified metabolic stability and CYP inhibition liability, and target engagement was confirmed by phosphoproteomic profiling in tumour xenografts."}
|
| 16 |
-
{"id": "psychology-cognition", "field": "Psychology / Neuroscience", "accept": ["3205", "3206", "3200", "2805", "2802"], "text": "Across three preregistered experiments we examined how working memory load modulates attentional capture by emotionally salient distractors. Participants performed a visual search task under concurrent memory demand while eye movements were recorded. Results indicate that capture persists under high load for threat-related stimuli but is attenuated for neutral salient items."}
|
| 17 |
-
{"id": "agriculture", "field": "Agricultural & Biological Sciences", "accept": ["1111", "1102", "1100", "2300", "1106"], "text": "We evaluate the effect of conservation tillage and cover cropping on soil organic carbon stocks and crop yield stability across a nine-year field experiment in a semi-arid cropping system. Soil sampling at multiple depths, combined with yield gap decomposition, indicates that carbon accrual is concentrated in surface horizons and that yield benefits emerge only in drier seasons."}
|
| 18 |
-
{"id": "veterinary", "field": "Veterinary", "accept": ["3404", "3400", "3401", "3403"], "text": "This retrospective cohort study describes clinical presentation, treatment and outcome in dogs diagnosed with immune-mediated haemolytic anaemia at a referral hospital over twelve years. Prognostic factors were assessed by multivariable survival analysis, and the association between transfusion requirement, thromboembolic complications and 30-day mortality was quantified."}
|
| 19 |
-
{"id": "dentistry", "field": "Dentistry", "accept": ["3504", "3506", "3500", "3501"], "text": "We compared marginal bone loss around platform-switched and conventional implant-abutment connections in a randomised split-mouth clinical trial. Sixty implants were placed in thirty partially edentulous patients, with standardised periapical radiography at baseline, six and twenty-four months, and peri-implant soft tissue parameters recorded alongside patient-reported outcomes."}
|
| 20 |
-
{"id": "open-access-policy", "field": "LIS / Scholarly Communication", "accept": ["3309", "3312", "1710", "3300"], "text": "This study analyses institutional compliance with funder open access mandates across a national university system. Combining repository deposit records with publisher policy data from Open Policy Finder, we quantify green and gold route uptake, embargo adherence and article processing charge expenditure, and identify institutional factors associated with higher compliance rates."}
|
|
|
|
| 1 |
+
{"id": "lis-chatbot", "field": "Library & Information Science", "accept": ["3309", "1710", "1706"], "text": "We present a retrieval-augmented generation chatbot integrated with the Primo discovery layer at a university library. The system combines dense vector retrieval over catalogue records and LibGuides content with a large language model to answer patron reference questions at the point of need. We evaluate retrieval precision, answer groundedness and user satisfaction across 1,200 real reference queries submitted by academic library users over one semester."}
|
| 2 |
+
{"id": "bibliometrics", "field": "Scientometrics", "accept": ["3309", "1706", "3312"], "text": "This scientometric analysis maps the growth of research output across national research systems using bibliometric indicators derived from Scopus and Web of Science. We compute field-normalised citation impact, international collaboration rates and disciplinary specialisation indices for 42 countries between 2000 and 2024, and examine the relationship between public research funding intensity and scholarly productivity."}
|
| 3 |
+
{"id": "cardiology-rct", "field": "Cardiology", "accept": ["2705", "2700", "2736"], "text": "This multicentre randomised controlled trial evaluated the efficacy and safety of direct oral anticoagulation in patients with non-valvular atrial fibrillation. 840 patients were randomised to receive either apixaban or dose-adjusted warfarin, with a primary composite endpoint of ischaemic stroke or systemic embolism at 24 months. Cardiovascular mortality, major bleeding and all-cause hospitalisation were prespecified secondary outcomes."}
|
| 4 |
+
{"id": "perovskite", "field": "Materials / Energy", "accept": ["2504", "2500", "2508", "2102", "3104", "3107", "1600", "2200"], "text": "We report a perovskite solar cell architecture achieving 25.6% power conversion efficiency. Thin-film deposition of methylammonium lead iodide was optimised through antisolvent engineering and interfacial passivation, and long-term device stability under continuous one-sun illumination was characterised over 1000 hours using X-ray diffraction, photoluminescence spectroscopy and impedance analysis."}
|
| 5 |
+
{"id": "nlp-transformer", "field": "Computer Science / AI", "accept": ["1702", "1706", "1712", "1700"], "text": "We introduce a parameter-efficient fine-tuning method for large transformer language models that adapts fewer than 0.1% of weights while matching full fine-tuning performance on downstream natural language understanding benchmarks. We evaluate on GLUE and SuperGLUE, analyse the rank structure of the learned updates, and show consistent gains in low-resource transfer settings."}
|
| 6 |
+
{"id": "groundwater", "field": "Earth / Environmental Science", "accept": ["2312", "1908", "1909", "1900", "2300", "2305", "2215"], "text": "We assess groundwater recharge dynamics in an arid alluvial aquifer using a coupled unsaturated-zone and finite-difference flow model calibrated against a decade of piezometric observations. Stable isotope signatures and chloride mass balance were used to partition recharge sources, and abstraction scenarios were simulated to evaluate long-term aquifer sustainability under projected climate forcing."}
|
| 7 |
+
{"id": "nursing-education", "field": "Nursing", "accept": ["2900", "2922", "2901", "3304", "2908"], "text": "This mixed-methods study examined the effect of high-fidelity simulation on clinical reasoning among undergraduate nursing students. Ninety-four students completed simulated deteriorating-patient scenarios followed by structured debriefing. Clinical reasoning was assessed with a validated rubric, and semi-structured interviews explored students' experiences of psychological safety during debriefing."}
|
| 8 |
+
{"id": "corporate-finance", "field": "Economics & Finance", "accept": ["2003", "2002", "2000", "1402", "1403"], "text": "We examine the effect of corporate governance quality on the cost of debt financing using a panel of listed firms across 18 emerging markets. Employing instrumental-variable estimation to address endogeneity, we find that board independence and disclosure quality are associated with materially lower credit spreads, with the effect concentrated among firms facing greater information asymmetry."}
|
| 9 |
+
{"id": "medieval-history", "field": "History / Humanities", "accept": ["1202", "1200", "1208"], "text": "Drawing on previously unexamined chancery records, this article reconstructs patterns of monastic landholding in the late medieval Rhineland. It argues that the conventional narrative of continuous ecclesiastical accumulation obscures a sustained period of alienation driven by fiscal pressure, and situates these transactions within wider debates about the economic agency of religious houses."}
|
| 10 |
+
{"id": "sociolinguistics", "field": "Linguistics / Arts & Humanities", "accept": ["3310", "1203", "1200", "3316"], "text": "This study investigates code-switching practices among second-generation multilingual speakers in an urban migration context. Using a corpus of naturalistic conversational recordings, we analyse patterns of intra-sentential switching and examine how speakers deploy linguistic resources to index identity and negotiate group membership across community settings."}
|
| 11 |
+
{"id": "supply-chain", "field": "Operations / Decision Sciences", "accept": ["1803", "1800", "1802", "2209", "1408"], "text": "We formulate a stochastic mixed-integer programming model for resilient supply chain network design under correlated disruption risk. A Benders decomposition scheme with valid inequalities is developed to solve realistically sized instances, and computational experiments on a multi-echelon distribution case demonstrate substantial cost savings over deterministic and robust-optimisation baselines."}
|
| 12 |
+
{"id": "immunology", "field": "Immunology & Microbiology", "accept": ["2403", "2404", "2400", "2406", "1312"], "text": "We characterise the tissue-resident memory T cell compartment in the human respiratory mucosa following viral infection. Single-cell RNA sequencing combined with flow cytometry revealed transcriptionally distinct subsets with divergent cytokine profiles, and adoptive transfer experiments in a murine model demonstrated that these populations confer durable protection against heterologous challenge."}
|
| 13 |
+
{"id": "civil-engineering", "field": "Civil / Structural Engineering", "accept": ["2205", "2200", "2215", "2210"], "text": "This paper presents a performance-based seismic assessment of reinforced concrete moment frames designed to pre-1980 detailing provisions. Nonlinear time-history analyses were conducted across a suite of ground motions scaled to multiple hazard levels, and fragility surfaces were developed to quantify collapse probability and inform retrofit prioritisation for existing building stock."}
|
| 14 |
+
{"id": "education-policy", "field": "Education", "accept": ["3304", "3300", "3312"], "text": "We evaluate the impact of a national school autonomy reform on student attainment using a difference-in-differences design across administrative regions. Drawing on eight years of standardised assessment data linked to school-level governance records, we find heterogeneous effects that vary systematically with baseline school capacity and local labour market conditions."}
|
| 15 |
+
{"id": "pharmacology", "field": "Pharmacology & Pharmaceutics", "accept": ["3004", "3003", "3002", "3000", "1313"], "text": "We report the pharmacokinetic and pharmacodynamic characterisation of a novel small-molecule kinase inhibitor in preclinical models. Dose-ranging studies established plasma exposure and tissue distribution, hepatic microsomal assays quantified metabolic stability and CYP inhibition liability, and target engagement was confirmed by phosphoproteomic profiling in tumour xenografts."}
|
| 16 |
+
{"id": "psychology-cognition", "field": "Psychology / Neuroscience", "accept": ["3205", "3206", "3200", "2805", "2802"], "text": "Across three preregistered experiments we examined how working memory load modulates attentional capture by emotionally salient distractors. Participants performed a visual search task under concurrent memory demand while eye movements were recorded. Results indicate that capture persists under high load for threat-related stimuli but is attenuated for neutral salient items."}
|
| 17 |
+
{"id": "agriculture", "field": "Agricultural & Biological Sciences", "accept": ["1111", "1102", "1100", "2300", "1106"], "text": "We evaluate the effect of conservation tillage and cover cropping on soil organic carbon stocks and crop yield stability across a nine-year field experiment in a semi-arid cropping system. Soil sampling at multiple depths, combined with yield gap decomposition, indicates that carbon accrual is concentrated in surface horizons and that yield benefits emerge only in drier seasons."}
|
| 18 |
+
{"id": "veterinary", "field": "Veterinary", "accept": ["3404", "3400", "3401", "3403"], "text": "This retrospective cohort study describes clinical presentation, treatment and outcome in dogs diagnosed with immune-mediated haemolytic anaemia at a referral hospital over twelve years. Prognostic factors were assessed by multivariable survival analysis, and the association between transfusion requirement, thromboembolic complications and 30-day mortality was quantified."}
|
| 19 |
+
{"id": "dentistry", "field": "Dentistry", "accept": ["3504", "3506", "3500", "3501"], "text": "We compared marginal bone loss around platform-switched and conventional implant-abutment connections in a randomised split-mouth clinical trial. Sixty implants were placed in thirty partially edentulous patients, with standardised periapical radiography at baseline, six and twenty-four months, and peri-implant soft tissue parameters recorded alongside patient-reported outcomes."}
|
| 20 |
+
{"id": "open-access-policy", "field": "LIS / Scholarly Communication", "accept": ["3309", "3312", "1710", "3300"], "text": "This study analyses institutional compliance with funder open access mandates across a national university system. Combining repository deposit records with publisher policy data from Open Policy Finder, we quantify green and gold route uptake, embargo adherence and article processing charge expenditure, and identify institutional factors associated with higher compliance rates."}
|
build_centroids.py
CHANGED
|
@@ -1,130 +1,130 @@
|
|
| 1 |
-
# /// script
|
| 2 |
-
# requires-python = ">=3.10"
|
| 3 |
-
# dependencies = ["numpy", "sentence-transformers", "huggingface_hub"]
|
| 4 |
-
# ///
|
| 5 |
-
"""
|
| 6 |
-
Turn the harvest into journal centroid vectors for the recommender's stage 2.
|
| 7 |
-
|
| 8 |
-
python build_centroids.py --db journals.db \
|
| 9 |
-
--journal-text journal_text.jsonl --out centroids.npz
|
| 10 |
-
|
| 11 |
-
Run this AFTER harvest_openalex.py. It does no network I/O of its own beyond
|
| 12 |
-
downloading the sentence-transformer weights once.
|
| 13 |
-
|
| 14 |
-
What gets embedded
|
| 15 |
-
------------------
|
| 16 |
-
Per journal, in descending order of signal:
|
| 17 |
-
|
| 18 |
-
1. OpenAlex topics, repeated in proportion to how much the journal publishes
|
| 19 |
-
on each β an article-derived fingerprint, pre-aggregated by OpenAlex.
|
| 20 |
-
2. The journal's ASJC subject names, from the index.
|
| 21 |
-
3. The journal title.
|
| 22 |
-
|
| 23 |
-
Journals OpenAlex could not match still get (2) and (3), so every journal has a
|
| 24 |
-
vector and none silently drops out of the reranking.
|
| 25 |
-
|
| 26 |
-
Cost note
|
| 27 |
-
---------
|
| 28 |
-
all-MiniLM-L6-v2 is 384-dimensional and ~90 MB. 32,050 active journals Γ 384
|
| 29 |
-
float32 = ~49 MB uncompressed, well under what a Space can hold in memory. A
|
| 30 |
-
larger model buys little here: the input is a bag of topic labels, not prose,
|
| 31 |
-
so the extra capacity has nothing to work with.
|
| 32 |
-
"""
|
| 33 |
-
from __future__ import annotations
|
| 34 |
-
|
| 35 |
-
import argparse
|
| 36 |
-
import json
|
| 37 |
-
import sqlite3
|
| 38 |
-
from pathlib import Path
|
| 39 |
-
|
| 40 |
-
import numpy as np
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def load_topics(path: str) -> dict[str, str]:
|
| 44 |
-
out: dict[str, str] = {}
|
| 45 |
-
p = Path(path)
|
| 46 |
-
if not p.exists():
|
| 47 |
-
print(f"note: {path} not found β building from index metadata only, "
|
| 48 |
-
f"which is much weaker. Run harvest_openalex.py first.")
|
| 49 |
-
return out
|
| 50 |
-
with p.open(encoding="utf-8") as f:
|
| 51 |
-
for line in f:
|
| 52 |
-
try:
|
| 53 |
-
rec = json.loads(line)
|
| 54 |
-
except json.JSONDecodeError:
|
| 55 |
-
continue
|
| 56 |
-
sid = rec.get("scopus_id")
|
| 57 |
-
if sid:
|
| 58 |
-
out[sid] = (out.get(sid, "") + " " + rec.get("topics", "")).strip()
|
| 59 |
-
return out
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def main() -> None:
|
| 63 |
-
ap = argparse.ArgumentParser()
|
| 64 |
-
ap.add_argument("--db", default="journals.db")
|
| 65 |
-
ap.add_argument("--journal-text", default="journal_text.jsonl")
|
| 66 |
-
ap.add_argument("--out", default="centroids.npz")
|
| 67 |
-
ap.add_argument("--model", default="sentence-transformers/all-MiniLM-L6-v2")
|
| 68 |
-
ap.add_argument("--batch-size", type=int, default=128)
|
| 69 |
-
ap.add_argument("--active-only", action="store_true", default=True)
|
| 70 |
-
ap.add_argument("--push-to-dataset", default=None)
|
| 71 |
-
args = ap.parse_args()
|
| 72 |
-
|
| 73 |
-
topics = load_topics(args.journal_text)
|
| 74 |
-
con = sqlite3.connect(args.db)
|
| 75 |
-
|
| 76 |
-
subs: dict[str, list[str]] = {}
|
| 77 |
-
for sid, name in con.execute(
|
| 78 |
-
"""SELECT ja.scopus_id, coalesce(a.name, a.parent_name)
|
| 79 |
-
FROM journal_asjc ja JOIN asjc a ON a.code = ja.asjc"""
|
| 80 |
-
):
|
| 81 |
-
subs.setdefault(sid, []).append(name or "")
|
| 82 |
-
|
| 83 |
-
where = "WHERE active = 1" if args.active_only else ""
|
| 84 |
-
rows = con.execute(f"SELECT scopus_id, title FROM journals {where}").fetchall()
|
| 85 |
-
con.close()
|
| 86 |
-
|
| 87 |
-
ids, docs = [], []
|
| 88 |
-
for sid, title in rows:
|
| 89 |
-
parts = [title or ""]
|
| 90 |
-
s = subs.get(sid)
|
| 91 |
-
if s:
|
| 92 |
-
parts.append(". ".join(sorted(set(s))))
|
| 93 |
-
t = topics.get(sid)
|
| 94 |
-
if t:
|
| 95 |
-
parts.append(t)
|
| 96 |
-
ids.append(sid)
|
| 97 |
-
docs.append(". ".join(p for p in parts if p)[:2000])
|
| 98 |
-
|
| 99 |
-
covered = sum(1 for i in ids if i in topics)
|
| 100 |
-
print(f"{len(ids)} journals; {covered} ({covered/max(len(ids),1):.0%}) "
|
| 101 |
-
f"have OpenAlex topics")
|
| 102 |
-
|
| 103 |
-
from sentence_transformers import SentenceTransformer
|
| 104 |
-
model = SentenceTransformer(args.model)
|
| 105 |
-
vecs = model.encode(
|
| 106 |
-
docs, batch_size=args.batch_size, show_progress_bar=True,
|
| 107 |
-
normalize_embeddings=True, convert_to_numpy=True,
|
| 108 |
-
).astype("float32")
|
| 109 |
-
|
| 110 |
-
np.savez_compressed(args.out, ids=np.array(ids), vecs=vecs)
|
| 111 |
-
size = Path(args.out).stat().st_size / 1e6
|
| 112 |
-
print(f"wrote {args.out} {vecs.shape} ({size:.1f} MB)")
|
| 113 |
-
|
| 114 |
-
if args.push_to_dataset:
|
| 115 |
-
from huggingface_hub import HfApi
|
| 116 |
-
api = HfApi()
|
| 117 |
-
api.create_repo(args.push_to_dataset, repo_type="dataset", exist_ok=True)
|
| 118 |
-
api.upload_file(path_or_fileobj=args.out, path_in_repo=Path(args.out).name,
|
| 119 |
-
repo_id=args.push_to_dataset, repo_type="dataset")
|
| 120 |
-
print(f"pushed {args.out} -> {args.push_to_dataset}")
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def load_centroids(path: str = "centroids.npz") -> dict[str, np.ndarray]:
|
| 124 |
-
"""Load into the {scopus_id: vector} mapping recommend() expects."""
|
| 125 |
-
d = np.load(path, allow_pickle=False)
|
| 126 |
-
return {str(i): v for i, v in zip(d["ids"], d["vecs"])}
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
if __name__ == "__main__":
|
| 130 |
-
main()
|
|
|
|
| 1 |
+
# /// script
|
| 2 |
+
# requires-python = ">=3.10"
|
| 3 |
+
# dependencies = ["numpy", "sentence-transformers", "huggingface_hub"]
|
| 4 |
+
# ///
|
| 5 |
+
"""
|
| 6 |
+
Turn the harvest into journal centroid vectors for the recommender's stage 2.
|
| 7 |
+
|
| 8 |
+
python build_centroids.py --db journals.db \
|
| 9 |
+
--journal-text journal_text.jsonl --out centroids.npz
|
| 10 |
+
|
| 11 |
+
Run this AFTER harvest_openalex.py. It does no network I/O of its own beyond
|
| 12 |
+
downloading the sentence-transformer weights once.
|
| 13 |
+
|
| 14 |
+
What gets embedded
|
| 15 |
+
------------------
|
| 16 |
+
Per journal, in descending order of signal:
|
| 17 |
+
|
| 18 |
+
1. OpenAlex topics, repeated in proportion to how much the journal publishes
|
| 19 |
+
on each β an article-derived fingerprint, pre-aggregated by OpenAlex.
|
| 20 |
+
2. The journal's ASJC subject names, from the index.
|
| 21 |
+
3. The journal title.
|
| 22 |
+
|
| 23 |
+
Journals OpenAlex could not match still get (2) and (3), so every journal has a
|
| 24 |
+
vector and none silently drops out of the reranking.
|
| 25 |
+
|
| 26 |
+
Cost note
|
| 27 |
+
---------
|
| 28 |
+
all-MiniLM-L6-v2 is 384-dimensional and ~90 MB. 32,050 active journals Γ 384
|
| 29 |
+
float32 = ~49 MB uncompressed, well under what a Space can hold in memory. A
|
| 30 |
+
larger model buys little here: the input is a bag of topic labels, not prose,
|
| 31 |
+
so the extra capacity has nothing to work with.
|
| 32 |
+
"""
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import argparse
|
| 36 |
+
import json
|
| 37 |
+
import sqlite3
|
| 38 |
+
from pathlib import Path
|
| 39 |
+
|
| 40 |
+
import numpy as np
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def load_topics(path: str) -> dict[str, str]:
|
| 44 |
+
out: dict[str, str] = {}
|
| 45 |
+
p = Path(path)
|
| 46 |
+
if not p.exists():
|
| 47 |
+
print(f"note: {path} not found β building from index metadata only, "
|
| 48 |
+
f"which is much weaker. Run harvest_openalex.py first.")
|
| 49 |
+
return out
|
| 50 |
+
with p.open(encoding="utf-8") as f:
|
| 51 |
+
for line in f:
|
| 52 |
+
try:
|
| 53 |
+
rec = json.loads(line)
|
| 54 |
+
except json.JSONDecodeError:
|
| 55 |
+
continue
|
| 56 |
+
sid = rec.get("scopus_id")
|
| 57 |
+
if sid:
|
| 58 |
+
out[sid] = (out.get(sid, "") + " " + rec.get("topics", "")).strip()
|
| 59 |
+
return out
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def main() -> None:
|
| 63 |
+
ap = argparse.ArgumentParser()
|
| 64 |
+
ap.add_argument("--db", default="journals.db")
|
| 65 |
+
ap.add_argument("--journal-text", default="journal_text.jsonl")
|
| 66 |
+
ap.add_argument("--out", default="centroids.npz")
|
| 67 |
+
ap.add_argument("--model", default="sentence-transformers/all-MiniLM-L6-v2")
|
| 68 |
+
ap.add_argument("--batch-size", type=int, default=128)
|
| 69 |
+
ap.add_argument("--active-only", action="store_true", default=True)
|
| 70 |
+
ap.add_argument("--push-to-dataset", default=None)
|
| 71 |
+
args = ap.parse_args()
|
| 72 |
+
|
| 73 |
+
topics = load_topics(args.journal_text)
|
| 74 |
+
con = sqlite3.connect(args.db)
|
| 75 |
+
|
| 76 |
+
subs: dict[str, list[str]] = {}
|
| 77 |
+
for sid, name in con.execute(
|
| 78 |
+
"""SELECT ja.scopus_id, coalesce(a.name, a.parent_name)
|
| 79 |
+
FROM journal_asjc ja JOIN asjc a ON a.code = ja.asjc"""
|
| 80 |
+
):
|
| 81 |
+
subs.setdefault(sid, []).append(name or "")
|
| 82 |
+
|
| 83 |
+
where = "WHERE active = 1" if args.active_only else ""
|
| 84 |
+
rows = con.execute(f"SELECT scopus_id, title FROM journals {where}").fetchall()
|
| 85 |
+
con.close()
|
| 86 |
+
|
| 87 |
+
ids, docs = [], []
|
| 88 |
+
for sid, title in rows:
|
| 89 |
+
parts = [title or ""]
|
| 90 |
+
s = subs.get(sid)
|
| 91 |
+
if s:
|
| 92 |
+
parts.append(". ".join(sorted(set(s))))
|
| 93 |
+
t = topics.get(sid)
|
| 94 |
+
if t:
|
| 95 |
+
parts.append(t)
|
| 96 |
+
ids.append(sid)
|
| 97 |
+
docs.append(". ".join(p for p in parts if p)[:2000])
|
| 98 |
+
|
| 99 |
+
covered = sum(1 for i in ids if i in topics)
|
| 100 |
+
print(f"{len(ids)} journals; {covered} ({covered/max(len(ids),1):.0%}) "
|
| 101 |
+
f"have OpenAlex topics")
|
| 102 |
+
|
| 103 |
+
from sentence_transformers import SentenceTransformer
|
| 104 |
+
model = SentenceTransformer(args.model)
|
| 105 |
+
vecs = model.encode(
|
| 106 |
+
docs, batch_size=args.batch_size, show_progress_bar=True,
|
| 107 |
+
normalize_embeddings=True, convert_to_numpy=True,
|
| 108 |
+
).astype("float32")
|
| 109 |
+
|
| 110 |
+
np.savez_compressed(args.out, ids=np.array(ids), vecs=vecs)
|
| 111 |
+
size = Path(args.out).stat().st_size / 1e6
|
| 112 |
+
print(f"wrote {args.out} {vecs.shape} ({size:.1f} MB)")
|
| 113 |
+
|
| 114 |
+
if args.push_to_dataset:
|
| 115 |
+
from huggingface_hub import HfApi
|
| 116 |
+
api = HfApi()
|
| 117 |
+
api.create_repo(args.push_to_dataset, repo_type="dataset", exist_ok=True)
|
| 118 |
+
api.upload_file(path_or_fileobj=args.out, path_in_repo=Path(args.out).name,
|
| 119 |
+
repo_id=args.push_to_dataset, repo_type="dataset")
|
| 120 |
+
print(f"pushed {args.out} -> {args.push_to_dataset}")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def load_centroids(path: str = "centroids.npz") -> dict[str, np.ndarray]:
|
| 124 |
+
"""Load into the {scopus_id: vector} mapping recommend() expects."""
|
| 125 |
+
d = np.load(path, allow_pickle=False)
|
| 126 |
+
return {str(i): v for i, v in zip(d["ids"], d["vecs"])}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
if __name__ == "__main__":
|
| 130 |
+
main()
|
doi_utils.py
CHANGED
|
@@ -1,92 +1,92 @@
|
|
| 1 |
-
"""
|
| 2 |
-
doi_utils.py β deterministic DOI extraction and ISSN validation.
|
| 3 |
-
|
| 4 |
-
No LLM. No guessing. If a DOI cannot be extracted, we say so and ask the user.
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
import re
|
| 10 |
-
from urllib.parse import unquote, urlparse
|
| 11 |
-
|
| 12 |
-
# DOIs are 10.<registrant>/<suffix>. The suffix may contain almost anything,
|
| 13 |
-
# so we trim trailing punctuation and known URL cruft afterwards.
|
| 14 |
-
DOI_RE = re.compile(r"(10\.\d{4,9}/[^\s\"'<>&?#]+)", re.I)
|
| 15 |
-
|
| 16 |
-
_TRAILING = ".,;:)]}>'\"-"
|
| 17 |
-
_SUFFIX_JUNK = (
|
| 18 |
-
"/full", "/abstract", "/pdf", "/epub", "/meta", "/html",
|
| 19 |
-
".full", ".pdf", ".abstract", ".long",
|
| 20 |
-
)
|
| 21 |
-
|
| 22 |
-
ISSN_RE = re.compile(r"^(\d{4})-?(\d{3}[\dXx])$")
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def extract_doi(text: str) -> str:
|
| 26 |
-
"""
|
| 27 |
-
Pull a DOI out of a URL, a citation, or raw text.
|
| 28 |
-
|
| 29 |
-
Handles publisher URL shapes:
|
| 30 |
-
https://www.tandfonline.com/doi/full/10.1080/19322909.2023.2221477
|
| 31 |
-
https://link.springer.com/article/10.1007/s11192-023-04812-4
|
| 32 |
-
https://doi.org/10.1016/j.jclepro.2023.136775
|
| 33 |
-
https://onlinelibrary.wiley.com/doi/10.1002/adma.202301234
|
| 34 |
-
10.1109/TCOMM.2023.1234567
|
| 35 |
-
Returns "" when no DOI is present.
|
| 36 |
-
"""
|
| 37 |
-
if not text:
|
| 38 |
-
return ""
|
| 39 |
-
s = unquote(str(text).strip())
|
| 40 |
-
|
| 41 |
-
m = DOI_RE.search(s)
|
| 42 |
-
if not m:
|
| 43 |
-
return ""
|
| 44 |
-
doi = m.group(1)
|
| 45 |
-
|
| 46 |
-
# Strip publisher URL suffixes appended after the DOI.
|
| 47 |
-
low = doi.lower()
|
| 48 |
-
for junk in _SUFFIX_JUNK:
|
| 49 |
-
if low.endswith(junk):
|
| 50 |
-
doi = doi[: -len(junk)]
|
| 51 |
-
low = doi.lower()
|
| 52 |
-
|
| 53 |
-
doi = doi.rstrip(_TRAILING)
|
| 54 |
-
return doi if "/" in doi and len(doi) > 7 else ""
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
def looks_like_url(text: str) -> bool:
|
| 58 |
-
try:
|
| 59 |
-
p = urlparse((text or "").strip())
|
| 60 |
-
return p.scheme in ("http", "https") and bool(p.netloc)
|
| 61 |
-
except ValueError:
|
| 62 |
-
return False
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def normalise_issn(raw: str) -> str:
|
| 66 |
-
"""Return a hyphenated, checksum-valid ISSN, or '' if invalid."""
|
| 67 |
-
m = ISSN_RE.match((raw or "").strip())
|
| 68 |
-
if not m:
|
| 69 |
-
return ""
|
| 70 |
-
digits = (m.group(1) + m.group(2)).upper()
|
| 71 |
-
if not issn_checksum_ok(digits):
|
| 72 |
-
return ""
|
| 73 |
-
return f"{digits[:4]}-{digits[4:]}"
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
def issn_checksum_ok(eight: str) -> bool:
|
| 77 |
-
"""ISSN mod-11 check digit. Catches typos before we hit the network."""
|
| 78 |
-
e = eight.replace("-", "").upper()
|
| 79 |
-
if len(e) != 8:
|
| 80 |
-
return False
|
| 81 |
-
try:
|
| 82 |
-
total = sum(int(e[i]) * (8 - i) for i in range(7))
|
| 83 |
-
except ValueError:
|
| 84 |
-
return False
|
| 85 |
-
remainder = total % 11
|
| 86 |
-
check = 0 if remainder == 0 else 11 - remainder
|
| 87 |
-
expected = "X" if check == 10 else str(check)
|
| 88 |
-
return e[7] == expected
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
def doi_url(doi: str) -> str:
|
| 92 |
-
return f"https://doi.org/{doi}" if doi else ""
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
doi_utils.py β deterministic DOI extraction and ISSN validation.
|
| 3 |
+
|
| 4 |
+
No LLM. No guessing. If a DOI cannot be extracted, we say so and ask the user.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
from urllib.parse import unquote, urlparse
|
| 11 |
+
|
| 12 |
+
# DOIs are 10.<registrant>/<suffix>. The suffix may contain almost anything,
|
| 13 |
+
# so we trim trailing punctuation and known URL cruft afterwards.
|
| 14 |
+
DOI_RE = re.compile(r"(10\.\d{4,9}/[^\s\"'<>&?#]+)", re.I)
|
| 15 |
+
|
| 16 |
+
_TRAILING = ".,;:)]}>'\"-"
|
| 17 |
+
_SUFFIX_JUNK = (
|
| 18 |
+
"/full", "/abstract", "/pdf", "/epub", "/meta", "/html",
|
| 19 |
+
".full", ".pdf", ".abstract", ".long",
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
ISSN_RE = re.compile(r"^(\d{4})-?(\d{3}[\dXx])$")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def extract_doi(text: str) -> str:
|
| 26 |
+
"""
|
| 27 |
+
Pull a DOI out of a URL, a citation, or raw text.
|
| 28 |
+
|
| 29 |
+
Handles publisher URL shapes:
|
| 30 |
+
https://www.tandfonline.com/doi/full/10.1080/19322909.2023.2221477
|
| 31 |
+
https://link.springer.com/article/10.1007/s11192-023-04812-4
|
| 32 |
+
https://doi.org/10.1016/j.jclepro.2023.136775
|
| 33 |
+
https://onlinelibrary.wiley.com/doi/10.1002/adma.202301234
|
| 34 |
+
10.1109/TCOMM.2023.1234567
|
| 35 |
+
Returns "" when no DOI is present.
|
| 36 |
+
"""
|
| 37 |
+
if not text:
|
| 38 |
+
return ""
|
| 39 |
+
s = unquote(str(text).strip())
|
| 40 |
+
|
| 41 |
+
m = DOI_RE.search(s)
|
| 42 |
+
if not m:
|
| 43 |
+
return ""
|
| 44 |
+
doi = m.group(1)
|
| 45 |
+
|
| 46 |
+
# Strip publisher URL suffixes appended after the DOI.
|
| 47 |
+
low = doi.lower()
|
| 48 |
+
for junk in _SUFFIX_JUNK:
|
| 49 |
+
if low.endswith(junk):
|
| 50 |
+
doi = doi[: -len(junk)]
|
| 51 |
+
low = doi.lower()
|
| 52 |
+
|
| 53 |
+
doi = doi.rstrip(_TRAILING)
|
| 54 |
+
return doi if "/" in doi and len(doi) > 7 else ""
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def looks_like_url(text: str) -> bool:
|
| 58 |
+
try:
|
| 59 |
+
p = urlparse((text or "").strip())
|
| 60 |
+
return p.scheme in ("http", "https") and bool(p.netloc)
|
| 61 |
+
except ValueError:
|
| 62 |
+
return False
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def normalise_issn(raw: str) -> str:
|
| 66 |
+
"""Return a hyphenated, checksum-valid ISSN, or '' if invalid."""
|
| 67 |
+
m = ISSN_RE.match((raw or "").strip())
|
| 68 |
+
if not m:
|
| 69 |
+
return ""
|
| 70 |
+
digits = (m.group(1) + m.group(2)).upper()
|
| 71 |
+
if not issn_checksum_ok(digits):
|
| 72 |
+
return ""
|
| 73 |
+
return f"{digits[:4]}-{digits[4:]}"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def issn_checksum_ok(eight: str) -> bool:
|
| 77 |
+
"""ISSN mod-11 check digit. Catches typos before we hit the network."""
|
| 78 |
+
e = eight.replace("-", "").upper()
|
| 79 |
+
if len(e) != 8:
|
| 80 |
+
return False
|
| 81 |
+
try:
|
| 82 |
+
total = sum(int(e[i]) * (8 - i) for i in range(7))
|
| 83 |
+
except ValueError:
|
| 84 |
+
return False
|
| 85 |
+
remainder = total % 11
|
| 86 |
+
check = 0 if remainder == 0 else 11 - remainder
|
| 87 |
+
expected = "X" if check == 10 else str(check)
|
| 88 |
+
return e[7] == expected
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def doi_url(doi: str) -> str:
|
| 92 |
+
return f"https://doi.org/{doi}" if doi else ""
|
harvest_openalex.py
CHANGED
|
@@ -1,353 +1,353 @@
|
|
| 1 |
-
# /// script
|
| 2 |
-
# requires-python = ">=3.10"
|
| 3 |
-
# dependencies = ["requests", "huggingface_hub"]
|
| 4 |
-
# ///
|
| 5 |
-
"""
|
| 6 |
-
One OpenAlex pass that produces BOTH deliverables:
|
| 7 |
-
|
| 8 |
-
journal_text.jsonl topical fingerprint per journal -> centroids (recommender stage 2)
|
| 9 |
-
asjc_train.jsonl labelled article abstracts -> ASJC classifier training
|
| 10 |
-
|
| 11 |
-
python harvest_openalex.py --db journals.db --mailto you@ku.ac.ae
|
| 12 |
-
python harvest_openalex.py --db journals.db --mailto you@ku.ac.ae --resume
|
| 13 |
-
|
| 14 |
-
These are the same job. The centroids need to know what each journal publishes
|
| 15 |
-
about; the classifier needs text labelled with subject codes. Both are answered
|
| 16 |
-
by reading OpenAlex once. Running them as separate harvests would double the
|
| 17 |
-
request count for no benefit.
|
| 18 |
-
|
| 19 |
-
Two phases
|
| 20 |
-
----------
|
| 21 |
-
PHASE 1 sources ~1,400 requests, minutes.
|
| 22 |
-
Cursor-pages every journal source and takes its `topics`, which OpenAlex has
|
| 23 |
-
already inferred from that journal's article corpus. An article-derived
|
| 24 |
-
signal delivered pre-aggregated β most of the value of reading the articles,
|
| 25 |
-
at ~4% of the requests.
|
| 26 |
-
|
| 27 |
-
PHASE 2 works ~8,000 requests, 1-2 hours.
|
| 28 |
-
Pulls recent abstracts per journal to train the classifier. Sampling is
|
| 29 |
-
STRATIFIED BY ASJC CODE, not uniform over journals: ASJC is wildly
|
| 30 |
-
imbalanced (History has 2,009 journals, Medical Terminology has 2), and
|
| 31 |
-
uniform sampling would produce a classifier that has never seen the rare
|
| 32 |
-
half of the taxonomy. `--per-code` journals are drawn per subject area, so
|
| 33 |
-
thin codes get represented.
|
| 34 |
-
|
| 35 |
-
Resumability
|
| 36 |
-
------------
|
| 37 |
-
HF Space containers restart without warning, and a two-hour harvest that loses
|
| 38 |
-
everything on restart is not a harvest. Both outputs are append-only JSONL and
|
| 39 |
-
progress is checkpointed to `<out>.state.json` after every batch. `--resume`
|
| 40 |
-
picks up from the last checkpoint. Re-running without `--resume` starts over.
|
| 41 |
-
|
| 42 |
-
Rate limits
|
| 43 |
-
-----------
|
| 44 |
-
OpenAlex allows 100k requests/day and ~10/sec in the polite pool. `--mailto` is
|
| 45 |
-
what puts you in that pool β without it you are throttled harder and less
|
| 46 |
-
predictably. It is required for a reason.
|
| 47 |
-
|
| 48 |
-
Where to run this
|
| 49 |
-
-----------------
|
| 50 |
-
Not inside a Space. A Space's disk is ephemeral β "content is lost if your Space
|
| 51 |
-
restarts or is stopped" β so a two-hour harvest writing 250k abstracts there is
|
| 52 |
-
one restart away from nothing, and free Spaces give you no terminal to launch it
|
| 53 |
-
from. Persistent storage on a Space is a paid Storage Bucket.
|
| 54 |
-
|
| 55 |
-
Run it as an HF Job instead β same infrastructure, purpose-built for exactly
|
| 56 |
-
this, billed per second:
|
| 57 |
-
|
| 58 |
-
hf jobs uv run --with requests,huggingface_hub \\
|
| 59 |
-
harvest_openalex.py -- --db journals.db --mailto you@ku.ac.ae \\
|
| 60 |
-
--push-to-dataset nikeshn/researchbee-index
|
| 61 |
-
|
| 62 |
-
The PEP 723 header above declares the dependencies, so `hf jobs uv run` resolves
|
| 63 |
-
them without a Dockerfile. `--push-to-dataset` uploads the outputs to a Dataset
|
| 64 |
-
repo as they complete, which is what makes the run durable: the Job container is
|
| 65 |
-
as ephemeral as the Space, so anything not pushed is lost when it exits.
|
| 66 |
-
"""
|
| 67 |
-
from __future__ import annotations
|
| 68 |
-
|
| 69 |
-
import argparse
|
| 70 |
-
import json
|
| 71 |
-
import random
|
| 72 |
-
import sqlite3
|
| 73 |
-
import time
|
| 74 |
-
from collections import defaultdict
|
| 75 |
-
from pathlib import Path
|
| 76 |
-
|
| 77 |
-
import requests
|
| 78 |
-
|
| 79 |
-
API = "https://api.openalex.org"
|
| 80 |
-
SESSION = requests.Session()
|
| 81 |
-
SESSION.headers.update({"User-Agent": "ResearchBee/1.0 (journal index build)"})
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
# ---------------------------------------------------------------------------
|
| 85 |
-
# HTTP
|
| 86 |
-
# ---------------------------------------------------------------------------
|
| 87 |
-
def get(url: str, params: dict, mailto: str, tries: int = 5) -> dict:
|
| 88 |
-
params = {**params, "mailto": mailto}
|
| 89 |
-
for attempt in range(tries):
|
| 90 |
-
try:
|
| 91 |
-
r = SESSION.get(url, params=params, timeout=60)
|
| 92 |
-
if r.status_code in (429, 503):
|
| 93 |
-
time.sleep(min(60, 2 ** attempt * 2))
|
| 94 |
-
continue
|
| 95 |
-
r.raise_for_status()
|
| 96 |
-
return r.json()
|
| 97 |
-
except requests.RequestException:
|
| 98 |
-
if attempt == tries - 1:
|
| 99 |
-
return {}
|
| 100 |
-
time.sleep(min(60, 2 ** attempt))
|
| 101 |
-
return {}
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def reconstruct_abstract(inv: dict | None) -> str:
|
| 105 |
-
"""
|
| 106 |
-
OpenAlex ships abstracts as an inverted index ({word: [positions]}) because
|
| 107 |
-
of publisher redistribution terms. Rebuild the linear text.
|
| 108 |
-
"""
|
| 109 |
-
if not inv:
|
| 110 |
-
return ""
|
| 111 |
-
positions: list[tuple[int, str]] = []
|
| 112 |
-
for word, idxs in inv.items():
|
| 113 |
-
for i in idxs:
|
| 114 |
-
positions.append((i, word))
|
| 115 |
-
if not positions:
|
| 116 |
-
return ""
|
| 117 |
-
positions.sort()
|
| 118 |
-
return " ".join(w for _, w in positions)
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
# ---------------------------------------------------------------------------
|
| 122 |
-
# Checkpointing
|
| 123 |
-
# ---------------------------------------------------------------------------
|
| 124 |
-
class Checkpoint:
|
| 125 |
-
def __init__(self, path: Path, resume: bool):
|
| 126 |
-
self.path = path
|
| 127 |
-
self.state = {"phase": 1, "cursor": "*", "done_journals": [], "n_works": 0}
|
| 128 |
-
if resume and path.exists():
|
| 129 |
-
self.state = json.loads(path.read_text())
|
| 130 |
-
print(f"resuming: phase {self.state['phase']}, "
|
| 131 |
-
f"{len(self.state['done_journals'])} journals done, "
|
| 132 |
-
f"{self.state['n_works']} abstracts written")
|
| 133 |
-
|
| 134 |
-
def save(self) -> None:
|
| 135 |
-
tmp = self.path.with_suffix(".tmp")
|
| 136 |
-
tmp.write_text(json.dumps(self.state))
|
| 137 |
-
tmp.replace(self.path) # atomic β a restart mid-write must not corrupt it
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
# ---------------------------------------------------------------------------
|
| 141 |
-
# Index helpers
|
| 142 |
-
# ---------------------------------------------------------------------------
|
| 143 |
-
def issn_to_scopus(con: sqlite3.Connection) -> dict[str, str]:
|
| 144 |
-
m: dict[str, str] = {}
|
| 145 |
-
for sid, p, e in con.execute(
|
| 146 |
-
"SELECT scopus_id, issn_print, issn_e FROM journals WHERE active = 1"
|
| 147 |
-
):
|
| 148 |
-
for i in (p, e):
|
| 149 |
-
if i:
|
| 150 |
-
m.setdefault(f"{i[:4]}-{i[4:]}", sid)
|
| 151 |
-
return m
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
def stratified_journals(con: sqlite3.Connection, per_code: int, seed: int) -> list[tuple[str, str]]:
|
| 155 |
-
"""
|
| 156 |
-
Pick up to `per_code` journals per ASJC code, preferring higher-SJR titles
|
| 157 |
-
(better-edited abstracts, cleaner topical signal), then de-duplicate.
|
| 158 |
-
Returns [(scopus_id, issn)].
|
| 159 |
-
"""
|
| 160 |
-
rng = random.Random(seed)
|
| 161 |
-
by_code: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
| 162 |
-
for sid, issn, code in con.execute(
|
| 163 |
-
"""SELECT j.scopus_id, coalesce(j.issn_e, j.issn_print), ja.asjc
|
| 164 |
-
FROM journals j JOIN journal_asjc ja ON ja.scopus_id = j.scopus_id
|
| 165 |
-
WHERE j.active = 1 AND j.source_type = 'Journal'
|
| 166 |
-
AND coalesce(j.issn_e, j.issn_print) IS NOT NULL
|
| 167 |
-
AND j.has_english = 1
|
| 168 |
-
ORDER BY j.sjr DESC NULLS LAST"""
|
| 169 |
-
):
|
| 170 |
-
by_code[code].append((sid, issn))
|
| 171 |
-
|
| 172 |
-
picked: dict[str, str] = {}
|
| 173 |
-
for code, rows in by_code.items():
|
| 174 |
-
# Take the strongest half, then sample within it, so the corpus is not
|
| 175 |
-
# entirely the same handful of megajournals per field.
|
| 176 |
-
pool = rows[: max(per_code * 3, per_code)]
|
| 177 |
-
rng.shuffle(pool)
|
| 178 |
-
for sid, issn in pool[:per_code]:
|
| 179 |
-
picked.setdefault(sid, issn)
|
| 180 |
-
return sorted(picked.items())
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
# ---------------------------------------------------------------------------
|
| 184 |
-
# Phase 1 β journal topics (centroids)
|
| 185 |
-
# ---------------------------------------------------------------------------
|
| 186 |
-
def phase1(con, mailto: str, out: Path, ck: Checkpoint) -> None:
|
| 187 |
-
keymap = issn_to_scopus(con)
|
| 188 |
-
seen_ids: set[str] = set()
|
| 189 |
-
if out.exists() and ck.state["phase"] > 1:
|
| 190 |
-
return
|
| 191 |
-
fh = out.open("a", encoding="utf-8")
|
| 192 |
-
if out.exists():
|
| 193 |
-
with out.open(encoding="utf-8") as f:
|
| 194 |
-
for line in f:
|
| 195 |
-
try:
|
| 196 |
-
seen_ids.add(json.loads(line)["scopus_id"])
|
| 197 |
-
except (json.JSONDecodeError, KeyError):
|
| 198 |
-
pass
|
| 199 |
-
|
| 200 |
-
cursor = ck.state.get("cursor") or "*"
|
| 201 |
-
page = 0
|
| 202 |
-
while cursor:
|
| 203 |
-
data = get(f"{API}/sources", {
|
| 204 |
-
"filter": "type:journal", "per-page": 200, "cursor": cursor,
|
| 205 |
-
"select": "id,display_name,issn,topics",
|
| 206 |
-
}, mailto)
|
| 207 |
-
results = data.get("results", [])
|
| 208 |
-
if not results:
|
| 209 |
-
break
|
| 210 |
-
for s in results:
|
| 211 |
-
sid = next((keymap[i] for i in (s.get("issn") or []) if i in keymap), None)
|
| 212 |
-
if not sid or sid in seen_ids:
|
| 213 |
-
continue
|
| 214 |
-
terms: list[str] = []
|
| 215 |
-
for t in (s.get("topics") or [])[:12]:
|
| 216 |
-
n = int(t.get("count", 1) or 1)
|
| 217 |
-
# Repeat proportional to prevalence so the embedding reflects
|
| 218 |
-
# what the journal mostly publishes, not its long tail.
|
| 219 |
-
terms += [t.get("display_name", "")] * max(1, min(4, n // 50))
|
| 220 |
-
for lvl in ("subfield", "field"):
|
| 221 |
-
if (t.get(lvl) or {}).get("display_name"):
|
| 222 |
-
terms.append(t[lvl]["display_name"])
|
| 223 |
-
if terms:
|
| 224 |
-
fh.write(json.dumps({"scopus_id": sid, "topics": " ".join(terms)}) + "\n")
|
| 225 |
-
seen_ids.add(sid)
|
| 226 |
-
fh.flush()
|
| 227 |
-
cursor = (data.get("meta") or {}).get("next_cursor")
|
| 228 |
-
ck.state["cursor"] = cursor
|
| 229 |
-
ck.save()
|
| 230 |
-
page += 1
|
| 231 |
-
if page % 10 == 0:
|
| 232 |
-
print(f" phase1 page {page} matched {len(seen_ids)}", flush=True)
|
| 233 |
-
|
| 234 |
-
fh.close()
|
| 235 |
-
ck.state.update(phase=2, cursor=None)
|
| 236 |
-
ck.save()
|
| 237 |
-
print(f"phase 1 done: {len(seen_ids)} journals with topics")
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
# ---------------------------------------------------------------------------
|
| 241 |
-
# Phase 2 β labelled abstracts (classifier)
|
| 242 |
-
# ---------------------------------------------------------------------------
|
| 243 |
-
def phase2(con, mailto: str, out: Path, ck: Checkpoint,
|
| 244 |
-
per_code: int, per_journal: int, seed: int, from_year: int) -> None:
|
| 245 |
-
targets = stratified_journals(con, per_code, seed)
|
| 246 |
-
done = set(ck.state["done_journals"])
|
| 247 |
-
todo = [(s, i) for s, i in targets if s not in done]
|
| 248 |
-
print(f"phase 2: {len(targets)} journals selected, {len(todo)} remaining")
|
| 249 |
-
|
| 250 |
-
fh = out.open("a", encoding="utf-8")
|
| 251 |
-
for n, (sid, issn) in enumerate(todo, 1):
|
| 252 |
-
fmt = f"{issn[:4]}-{issn[4:]}"
|
| 253 |
-
data = get(f"{API}/works", {
|
| 254 |
-
"filter": (f"primary_location.source.issn:{fmt},"
|
| 255 |
-
f"from_publication_date:{from_year}-01-01,"
|
| 256 |
-
"type:article,has_abstract:true"),
|
| 257 |
-
"per-page": per_journal,
|
| 258 |
-
"select": "title,abstract_inverted_index",
|
| 259 |
-
}, mailto)
|
| 260 |
-
wrote = 0
|
| 261 |
-
for w in data.get("results", []):
|
| 262 |
-
abstract = reconstruct_abstract(w.get("abstract_inverted_index"))
|
| 263 |
-
if len(abstract) < 200:
|
| 264 |
-
continue
|
| 265 |
-
text = f"{w.get('title') or ''}. {abstract}".strip()
|
| 266 |
-
fh.write(json.dumps({"scopus_id": sid, "text": text[:4000]}) + "\n")
|
| 267 |
-
wrote += 1
|
| 268 |
-
ck.state["n_works"] += wrote
|
| 269 |
-
done.add(sid)
|
| 270 |
-
|
| 271 |
-
if n % 25 == 0:
|
| 272 |
-
fh.flush()
|
| 273 |
-
ck.state["done_journals"] = sorted(done)
|
| 274 |
-
ck.save()
|
| 275 |
-
print(f" phase2 {n}/{len(todo)} journals, "
|
| 276 |
-
f"{ck.state['n_works']} abstracts", flush=True)
|
| 277 |
-
time.sleep(0.11) # ~9 req/s, inside the polite-pool ceiling
|
| 278 |
-
|
| 279 |
-
fh.flush()
|
| 280 |
-
fh.close()
|
| 281 |
-
ck.state["done_journals"] = sorted(done)
|
| 282 |
-
ck.state["phase"] = 3
|
| 283 |
-
ck.save()
|
| 284 |
-
print(f"phase 2 done: {ck.state['n_works']} abstracts")
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
def main() -> None:
|
| 288 |
-
ap = argparse.ArgumentParser()
|
| 289 |
-
ap.add_argument("--db", default="journals.db")
|
| 290 |
-
ap.add_argument("--mailto", required=True,
|
| 291 |
-
help="your email β required for OpenAlex's polite pool")
|
| 292 |
-
ap.add_argument("--resume", action="store_true")
|
| 293 |
-
ap.add_argument("--skip-phase1", action="store_true")
|
| 294 |
-
ap.add_argument("--skip-phase2", action="store_true")
|
| 295 |
-
ap.add_argument("--journal-text", default="journal_text.jsonl")
|
| 296 |
-
ap.add_argument("--train-out", default="asjc_train.jsonl")
|
| 297 |
-
ap.add_argument("--per-code", type=int, default=25,
|
| 298 |
-
help="journals sampled per ASJC code (stratification depth)")
|
| 299 |
-
ap.add_argument("--per-journal", type=int, default=40,
|
| 300 |
-
help="abstracts pulled per journal")
|
| 301 |
-
ap.add_argument("--from-year", type=int, default=2021)
|
| 302 |
-
ap.add_argument("--seed", type=int, default=42)
|
| 303 |
-
ap.add_argument("--push-to-dataset", default=None,
|
| 304 |
-
help="HF Dataset repo id to upload outputs to, e.g. user/researchbee-index. "
|
| 305 |
-
"Required for Job runs β the container's disk does not survive it.")
|
| 306 |
-
args = ap.parse_args()
|
| 307 |
-
|
| 308 |
-
con = sqlite3.connect(args.db)
|
| 309 |
-
ck = Checkpoint(Path(args.train_out + ".state.json"), args.resume)
|
| 310 |
-
|
| 311 |
-
if not args.resume:
|
| 312 |
-
for p in (args.journal_text, args.train_out):
|
| 313 |
-
Path(p).unlink(missing_ok=True)
|
| 314 |
-
|
| 315 |
-
if not args.skip_phase1 and ck.state["phase"] <= 1:
|
| 316 |
-
phase1(con, args.mailto, Path(args.journal_text), ck)
|
| 317 |
-
if not args.skip_phase2 and ck.state["phase"] <= 2:
|
| 318 |
-
phase2(con, args.mailto, Path(args.train_out), ck,
|
| 319 |
-
args.per_code, args.per_journal, args.seed, args.from_year)
|
| 320 |
-
|
| 321 |
-
for p in (args.journal_text, args.train_out):
|
| 322 |
-
path = Path(p)
|
| 323 |
-
if path.exists():
|
| 324 |
-
n = sum(1 for _ in path.open(encoding="utf-8"))
|
| 325 |
-
print(f"{p}: {n} lines, {path.stat().st_size/1e6:.1f} MB")
|
| 326 |
-
con.close()
|
| 327 |
-
|
| 328 |
-
if args.push_to_dataset:
|
| 329 |
-
push(args.push_to_dataset, [args.journal_text, args.train_out,
|
| 330 |
-
args.train_out + ".state.json"])
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
def push(repo_id: str, paths: list[str]) -> None:
|
| 334 |
-
"""
|
| 335 |
-
Upload outputs to a Dataset repo. This is not a convenience β a Job's disk
|
| 336 |
-
is discarded when the run ends, so anything not pushed did not happen.
|
| 337 |
-
"""
|
| 338 |
-
from huggingface_hub import HfApi
|
| 339 |
-
api = HfApi()
|
| 340 |
-
api.create_repo(repo_id, repo_type="dataset", exist_ok=True)
|
| 341 |
-
for p in paths:
|
| 342 |
-
path = Path(p)
|
| 343 |
-
if not path.exists():
|
| 344 |
-
continue
|
| 345 |
-
api.upload_file(
|
| 346 |
-
path_or_fileobj=str(path), path_in_repo=path.name,
|
| 347 |
-
repo_id=repo_id, repo_type="dataset",
|
| 348 |
-
)
|
| 349 |
-
print(f"pushed {path.name} -> {repo_id}")
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
if __name__ == "__main__":
|
| 353 |
-
main()
|
|
|
|
| 1 |
+
# /// script
|
| 2 |
+
# requires-python = ">=3.10"
|
| 3 |
+
# dependencies = ["requests", "huggingface_hub"]
|
| 4 |
+
# ///
|
| 5 |
+
"""
|
| 6 |
+
One OpenAlex pass that produces BOTH deliverables:
|
| 7 |
+
|
| 8 |
+
journal_text.jsonl topical fingerprint per journal -> centroids (recommender stage 2)
|
| 9 |
+
asjc_train.jsonl labelled article abstracts -> ASJC classifier training
|
| 10 |
+
|
| 11 |
+
python harvest_openalex.py --db journals.db --mailto you@ku.ac.ae
|
| 12 |
+
python harvest_openalex.py --db journals.db --mailto you@ku.ac.ae --resume
|
| 13 |
+
|
| 14 |
+
These are the same job. The centroids need to know what each journal publishes
|
| 15 |
+
about; the classifier needs text labelled with subject codes. Both are answered
|
| 16 |
+
by reading OpenAlex once. Running them as separate harvests would double the
|
| 17 |
+
request count for no benefit.
|
| 18 |
+
|
| 19 |
+
Two phases
|
| 20 |
+
----------
|
| 21 |
+
PHASE 1 sources ~1,400 requests, minutes.
|
| 22 |
+
Cursor-pages every journal source and takes its `topics`, which OpenAlex has
|
| 23 |
+
already inferred from that journal's article corpus. An article-derived
|
| 24 |
+
signal delivered pre-aggregated β most of the value of reading the articles,
|
| 25 |
+
at ~4% of the requests.
|
| 26 |
+
|
| 27 |
+
PHASE 2 works ~8,000 requests, 1-2 hours.
|
| 28 |
+
Pulls recent abstracts per journal to train the classifier. Sampling is
|
| 29 |
+
STRATIFIED BY ASJC CODE, not uniform over journals: ASJC is wildly
|
| 30 |
+
imbalanced (History has 2,009 journals, Medical Terminology has 2), and
|
| 31 |
+
uniform sampling would produce a classifier that has never seen the rare
|
| 32 |
+
half of the taxonomy. `--per-code` journals are drawn per subject area, so
|
| 33 |
+
thin codes get represented.
|
| 34 |
+
|
| 35 |
+
Resumability
|
| 36 |
+
------------
|
| 37 |
+
HF Space containers restart without warning, and a two-hour harvest that loses
|
| 38 |
+
everything on restart is not a harvest. Both outputs are append-only JSONL and
|
| 39 |
+
progress is checkpointed to `<out>.state.json` after every batch. `--resume`
|
| 40 |
+
picks up from the last checkpoint. Re-running without `--resume` starts over.
|
| 41 |
+
|
| 42 |
+
Rate limits
|
| 43 |
+
-----------
|
| 44 |
+
OpenAlex allows 100k requests/day and ~10/sec in the polite pool. `--mailto` is
|
| 45 |
+
what puts you in that pool β without it you are throttled harder and less
|
| 46 |
+
predictably. It is required for a reason.
|
| 47 |
+
|
| 48 |
+
Where to run this
|
| 49 |
+
-----------------
|
| 50 |
+
Not inside a Space. A Space's disk is ephemeral β "content is lost if your Space
|
| 51 |
+
restarts or is stopped" β so a two-hour harvest writing 250k abstracts there is
|
| 52 |
+
one restart away from nothing, and free Spaces give you no terminal to launch it
|
| 53 |
+
from. Persistent storage on a Space is a paid Storage Bucket.
|
| 54 |
+
|
| 55 |
+
Run it as an HF Job instead β same infrastructure, purpose-built for exactly
|
| 56 |
+
this, billed per second:
|
| 57 |
+
|
| 58 |
+
hf jobs uv run --with requests,huggingface_hub \\
|
| 59 |
+
harvest_openalex.py -- --db journals.db --mailto you@ku.ac.ae \\
|
| 60 |
+
--push-to-dataset nikeshn/researchbee-index
|
| 61 |
+
|
| 62 |
+
The PEP 723 header above declares the dependencies, so `hf jobs uv run` resolves
|
| 63 |
+
them without a Dockerfile. `--push-to-dataset` uploads the outputs to a Dataset
|
| 64 |
+
repo as they complete, which is what makes the run durable: the Job container is
|
| 65 |
+
as ephemeral as the Space, so anything not pushed is lost when it exits.
|
| 66 |
+
"""
|
| 67 |
+
from __future__ import annotations
|
| 68 |
+
|
| 69 |
+
import argparse
|
| 70 |
+
import json
|
| 71 |
+
import random
|
| 72 |
+
import sqlite3
|
| 73 |
+
import time
|
| 74 |
+
from collections import defaultdict
|
| 75 |
+
from pathlib import Path
|
| 76 |
+
|
| 77 |
+
import requests
|
| 78 |
+
|
| 79 |
+
API = "https://api.openalex.org"
|
| 80 |
+
SESSION = requests.Session()
|
| 81 |
+
SESSION.headers.update({"User-Agent": "ResearchBee/1.0 (journal index build)"})
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
# HTTP
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
def get(url: str, params: dict, mailto: str, tries: int = 5) -> dict:
|
| 88 |
+
params = {**params, "mailto": mailto}
|
| 89 |
+
for attempt in range(tries):
|
| 90 |
+
try:
|
| 91 |
+
r = SESSION.get(url, params=params, timeout=60)
|
| 92 |
+
if r.status_code in (429, 503):
|
| 93 |
+
time.sleep(min(60, 2 ** attempt * 2))
|
| 94 |
+
continue
|
| 95 |
+
r.raise_for_status()
|
| 96 |
+
return r.json()
|
| 97 |
+
except requests.RequestException:
|
| 98 |
+
if attempt == tries - 1:
|
| 99 |
+
return {}
|
| 100 |
+
time.sleep(min(60, 2 ** attempt))
|
| 101 |
+
return {}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def reconstruct_abstract(inv: dict | None) -> str:
|
| 105 |
+
"""
|
| 106 |
+
OpenAlex ships abstracts as an inverted index ({word: [positions]}) because
|
| 107 |
+
of publisher redistribution terms. Rebuild the linear text.
|
| 108 |
+
"""
|
| 109 |
+
if not inv:
|
| 110 |
+
return ""
|
| 111 |
+
positions: list[tuple[int, str]] = []
|
| 112 |
+
for word, idxs in inv.items():
|
| 113 |
+
for i in idxs:
|
| 114 |
+
positions.append((i, word))
|
| 115 |
+
if not positions:
|
| 116 |
+
return ""
|
| 117 |
+
positions.sort()
|
| 118 |
+
return " ".join(w for _, w in positions)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ---------------------------------------------------------------------------
|
| 122 |
+
# Checkpointing
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
class Checkpoint:
|
| 125 |
+
def __init__(self, path: Path, resume: bool):
|
| 126 |
+
self.path = path
|
| 127 |
+
self.state = {"phase": 1, "cursor": "*", "done_journals": [], "n_works": 0}
|
| 128 |
+
if resume and path.exists():
|
| 129 |
+
self.state = json.loads(path.read_text())
|
| 130 |
+
print(f"resuming: phase {self.state['phase']}, "
|
| 131 |
+
f"{len(self.state['done_journals'])} journals done, "
|
| 132 |
+
f"{self.state['n_works']} abstracts written")
|
| 133 |
+
|
| 134 |
+
def save(self) -> None:
|
| 135 |
+
tmp = self.path.with_suffix(".tmp")
|
| 136 |
+
tmp.write_text(json.dumps(self.state))
|
| 137 |
+
tmp.replace(self.path) # atomic β a restart mid-write must not corrupt it
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ---------------------------------------------------------------------------
|
| 141 |
+
# Index helpers
|
| 142 |
+
# ---------------------------------------------------------------------------
|
| 143 |
+
def issn_to_scopus(con: sqlite3.Connection) -> dict[str, str]:
|
| 144 |
+
m: dict[str, str] = {}
|
| 145 |
+
for sid, p, e in con.execute(
|
| 146 |
+
"SELECT scopus_id, issn_print, issn_e FROM journals WHERE active = 1"
|
| 147 |
+
):
|
| 148 |
+
for i in (p, e):
|
| 149 |
+
if i:
|
| 150 |
+
m.setdefault(f"{i[:4]}-{i[4:]}", sid)
|
| 151 |
+
return m
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def stratified_journals(con: sqlite3.Connection, per_code: int, seed: int) -> list[tuple[str, str]]:
|
| 155 |
+
"""
|
| 156 |
+
Pick up to `per_code` journals per ASJC code, preferring higher-SJR titles
|
| 157 |
+
(better-edited abstracts, cleaner topical signal), then de-duplicate.
|
| 158 |
+
Returns [(scopus_id, issn)].
|
| 159 |
+
"""
|
| 160 |
+
rng = random.Random(seed)
|
| 161 |
+
by_code: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
| 162 |
+
for sid, issn, code in con.execute(
|
| 163 |
+
"""SELECT j.scopus_id, coalesce(j.issn_e, j.issn_print), ja.asjc
|
| 164 |
+
FROM journals j JOIN journal_asjc ja ON ja.scopus_id = j.scopus_id
|
| 165 |
+
WHERE j.active = 1 AND j.source_type = 'Journal'
|
| 166 |
+
AND coalesce(j.issn_e, j.issn_print) IS NOT NULL
|
| 167 |
+
AND j.has_english = 1
|
| 168 |
+
ORDER BY j.sjr DESC NULLS LAST"""
|
| 169 |
+
):
|
| 170 |
+
by_code[code].append((sid, issn))
|
| 171 |
+
|
| 172 |
+
picked: dict[str, str] = {}
|
| 173 |
+
for code, rows in by_code.items():
|
| 174 |
+
# Take the strongest half, then sample within it, so the corpus is not
|
| 175 |
+
# entirely the same handful of megajournals per field.
|
| 176 |
+
pool = rows[: max(per_code * 3, per_code)]
|
| 177 |
+
rng.shuffle(pool)
|
| 178 |
+
for sid, issn in pool[:per_code]:
|
| 179 |
+
picked.setdefault(sid, issn)
|
| 180 |
+
return sorted(picked.items())
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ---------------------------------------------------------------------------
|
| 184 |
+
# Phase 1 β journal topics (centroids)
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
def phase1(con, mailto: str, out: Path, ck: Checkpoint) -> None:
|
| 187 |
+
keymap = issn_to_scopus(con)
|
| 188 |
+
seen_ids: set[str] = set()
|
| 189 |
+
if out.exists() and ck.state["phase"] > 1:
|
| 190 |
+
return
|
| 191 |
+
fh = out.open("a", encoding="utf-8")
|
| 192 |
+
if out.exists():
|
| 193 |
+
with out.open(encoding="utf-8") as f:
|
| 194 |
+
for line in f:
|
| 195 |
+
try:
|
| 196 |
+
seen_ids.add(json.loads(line)["scopus_id"])
|
| 197 |
+
except (json.JSONDecodeError, KeyError):
|
| 198 |
+
pass
|
| 199 |
+
|
| 200 |
+
cursor = ck.state.get("cursor") or "*"
|
| 201 |
+
page = 0
|
| 202 |
+
while cursor:
|
| 203 |
+
data = get(f"{API}/sources", {
|
| 204 |
+
"filter": "type:journal", "per-page": 200, "cursor": cursor,
|
| 205 |
+
"select": "id,display_name,issn,topics",
|
| 206 |
+
}, mailto)
|
| 207 |
+
results = data.get("results", [])
|
| 208 |
+
if not results:
|
| 209 |
+
break
|
| 210 |
+
for s in results:
|
| 211 |
+
sid = next((keymap[i] for i in (s.get("issn") or []) if i in keymap), None)
|
| 212 |
+
if not sid or sid in seen_ids:
|
| 213 |
+
continue
|
| 214 |
+
terms: list[str] = []
|
| 215 |
+
for t in (s.get("topics") or [])[:12]:
|
| 216 |
+
n = int(t.get("count", 1) or 1)
|
| 217 |
+
# Repeat proportional to prevalence so the embedding reflects
|
| 218 |
+
# what the journal mostly publishes, not its long tail.
|
| 219 |
+
terms += [t.get("display_name", "")] * max(1, min(4, n // 50))
|
| 220 |
+
for lvl in ("subfield", "field"):
|
| 221 |
+
if (t.get(lvl) or {}).get("display_name"):
|
| 222 |
+
terms.append(t[lvl]["display_name"])
|
| 223 |
+
if terms:
|
| 224 |
+
fh.write(json.dumps({"scopus_id": sid, "topics": " ".join(terms)}) + "\n")
|
| 225 |
+
seen_ids.add(sid)
|
| 226 |
+
fh.flush()
|
| 227 |
+
cursor = (data.get("meta") or {}).get("next_cursor")
|
| 228 |
+
ck.state["cursor"] = cursor
|
| 229 |
+
ck.save()
|
| 230 |
+
page += 1
|
| 231 |
+
if page % 10 == 0:
|
| 232 |
+
print(f" phase1 page {page} matched {len(seen_ids)}", flush=True)
|
| 233 |
+
|
| 234 |
+
fh.close()
|
| 235 |
+
ck.state.update(phase=2, cursor=None)
|
| 236 |
+
ck.save()
|
| 237 |
+
print(f"phase 1 done: {len(seen_ids)} journals with topics")
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ---------------------------------------------------------------------------
|
| 241 |
+
# Phase 2 β labelled abstracts (classifier)
|
| 242 |
+
# ---------------------------------------------------------------------------
|
| 243 |
+
def phase2(con, mailto: str, out: Path, ck: Checkpoint,
|
| 244 |
+
per_code: int, per_journal: int, seed: int, from_year: int) -> None:
|
| 245 |
+
targets = stratified_journals(con, per_code, seed)
|
| 246 |
+
done = set(ck.state["done_journals"])
|
| 247 |
+
todo = [(s, i) for s, i in targets if s not in done]
|
| 248 |
+
print(f"phase 2: {len(targets)} journals selected, {len(todo)} remaining")
|
| 249 |
+
|
| 250 |
+
fh = out.open("a", encoding="utf-8")
|
| 251 |
+
for n, (sid, issn) in enumerate(todo, 1):
|
| 252 |
+
fmt = f"{issn[:4]}-{issn[4:]}"
|
| 253 |
+
data = get(f"{API}/works", {
|
| 254 |
+
"filter": (f"primary_location.source.issn:{fmt},"
|
| 255 |
+
f"from_publication_date:{from_year}-01-01,"
|
| 256 |
+
"type:article,has_abstract:true"),
|
| 257 |
+
"per-page": per_journal,
|
| 258 |
+
"select": "title,abstract_inverted_index",
|
| 259 |
+
}, mailto)
|
| 260 |
+
wrote = 0
|
| 261 |
+
for w in data.get("results", []):
|
| 262 |
+
abstract = reconstruct_abstract(w.get("abstract_inverted_index"))
|
| 263 |
+
if len(abstract) < 200:
|
| 264 |
+
continue
|
| 265 |
+
text = f"{w.get('title') or ''}. {abstract}".strip()
|
| 266 |
+
fh.write(json.dumps({"scopus_id": sid, "text": text[:4000]}) + "\n")
|
| 267 |
+
wrote += 1
|
| 268 |
+
ck.state["n_works"] += wrote
|
| 269 |
+
done.add(sid)
|
| 270 |
+
|
| 271 |
+
if n % 25 == 0:
|
| 272 |
+
fh.flush()
|
| 273 |
+
ck.state["done_journals"] = sorted(done)
|
| 274 |
+
ck.save()
|
| 275 |
+
print(f" phase2 {n}/{len(todo)} journals, "
|
| 276 |
+
f"{ck.state['n_works']} abstracts", flush=True)
|
| 277 |
+
time.sleep(0.11) # ~9 req/s, inside the polite-pool ceiling
|
| 278 |
+
|
| 279 |
+
fh.flush()
|
| 280 |
+
fh.close()
|
| 281 |
+
ck.state["done_journals"] = sorted(done)
|
| 282 |
+
ck.state["phase"] = 3
|
| 283 |
+
ck.save()
|
| 284 |
+
print(f"phase 2 done: {ck.state['n_works']} abstracts")
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def main() -> None:
|
| 288 |
+
ap = argparse.ArgumentParser()
|
| 289 |
+
ap.add_argument("--db", default="journals.db")
|
| 290 |
+
ap.add_argument("--mailto", required=True,
|
| 291 |
+
help="your email β required for OpenAlex's polite pool")
|
| 292 |
+
ap.add_argument("--resume", action="store_true")
|
| 293 |
+
ap.add_argument("--skip-phase1", action="store_true")
|
| 294 |
+
ap.add_argument("--skip-phase2", action="store_true")
|
| 295 |
+
ap.add_argument("--journal-text", default="journal_text.jsonl")
|
| 296 |
+
ap.add_argument("--train-out", default="asjc_train.jsonl")
|
| 297 |
+
ap.add_argument("--per-code", type=int, default=25,
|
| 298 |
+
help="journals sampled per ASJC code (stratification depth)")
|
| 299 |
+
ap.add_argument("--per-journal", type=int, default=40,
|
| 300 |
+
help="abstracts pulled per journal")
|
| 301 |
+
ap.add_argument("--from-year", type=int, default=2021)
|
| 302 |
+
ap.add_argument("--seed", type=int, default=42)
|
| 303 |
+
ap.add_argument("--push-to-dataset", default=None,
|
| 304 |
+
help="HF Dataset repo id to upload outputs to, e.g. user/researchbee-index. "
|
| 305 |
+
"Required for Job runs β the container's disk does not survive it.")
|
| 306 |
+
args = ap.parse_args()
|
| 307 |
+
|
| 308 |
+
con = sqlite3.connect(args.db)
|
| 309 |
+
ck = Checkpoint(Path(args.train_out + ".state.json"), args.resume)
|
| 310 |
+
|
| 311 |
+
if not args.resume:
|
| 312 |
+
for p in (args.journal_text, args.train_out):
|
| 313 |
+
Path(p).unlink(missing_ok=True)
|
| 314 |
+
|
| 315 |
+
if not args.skip_phase1 and ck.state["phase"] <= 1:
|
| 316 |
+
phase1(con, args.mailto, Path(args.journal_text), ck)
|
| 317 |
+
if not args.skip_phase2 and ck.state["phase"] <= 2:
|
| 318 |
+
phase2(con, args.mailto, Path(args.train_out), ck,
|
| 319 |
+
args.per_code, args.per_journal, args.seed, args.from_year)
|
| 320 |
+
|
| 321 |
+
for p in (args.journal_text, args.train_out):
|
| 322 |
+
path = Path(p)
|
| 323 |
+
if path.exists():
|
| 324 |
+
n = sum(1 for _ in path.open(encoding="utf-8"))
|
| 325 |
+
print(f"{p}: {n} lines, {path.stat().st_size/1e6:.1f} MB")
|
| 326 |
+
con.close()
|
| 327 |
+
|
| 328 |
+
if args.push_to_dataset:
|
| 329 |
+
push(args.push_to_dataset, [args.journal_text, args.train_out,
|
| 330 |
+
args.train_out + ".state.json"])
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def push(repo_id: str, paths: list[str]) -> None:
|
| 334 |
+
"""
|
| 335 |
+
Upload outputs to a Dataset repo. This is not a convenience β a Job's disk
|
| 336 |
+
is discarded when the run ends, so anything not pushed did not happen.
|
| 337 |
+
"""
|
| 338 |
+
from huggingface_hub import HfApi
|
| 339 |
+
api = HfApi()
|
| 340 |
+
api.create_repo(repo_id, repo_type="dataset", exist_ok=True)
|
| 341 |
+
for p in paths:
|
| 342 |
+
path = Path(p)
|
| 343 |
+
if not path.exists():
|
| 344 |
+
continue
|
| 345 |
+
api.upload_file(
|
| 346 |
+
path_or_fileobj=str(path), path_in_repo=path.name,
|
| 347 |
+
repo_id=repo_id, repo_type="dataset",
|
| 348 |
+
)
|
| 349 |
+
print(f"pushed {path.name} -> {repo_id}")
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
if __name__ == "__main__":
|
| 353 |
+
main()
|
journal_lookup.py
CHANGED
|
@@ -1,225 +1,225 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Drop-in replacement for scimago_index, backed by journals.db.
|
| 3 |
-
|
| 4 |
-
Swap ONE import in app.py and nothing else:
|
| 5 |
-
|
| 6 |
-
- from scimago_index import (lookup_any, load_scimago, search_by_subject, ...)
|
| 7 |
-
+ from journal_lookup import (lookup_any, load_scimago, search_by_subject, ...)
|
| 8 |
-
|
| 9 |
-
Keep the import list exactly as it is β however long it is, and whatever gets
|
| 10 |
-
added to it later.
|
| 11 |
-
|
| 12 |
-
Why it forwards everything instead of naming functions
|
| 13 |
-
-------------------------------------------------------
|
| 14 |
-
The first version of this file re-exported four functions by name. That broke
|
| 15 |
-
the moment `scimago_index` grew `subject_vocabulary`, `is_real_category`,
|
| 16 |
-
`is_real_area` and `suggest_categories` β the import in app.py would have
|
| 17 |
-
raised ImportError at startup and taken the Space down.
|
| 18 |
-
|
| 19 |
-
So this module now forwards **every** attribute it does not define itself to
|
| 20 |
-
`scimago_index`, via PEP 562 module `__getattr__`. Add ten more functions to
|
| 21 |
-
`scimago_index` tomorrow and this file needs no change. The only name it
|
| 22 |
-
deliberately overrides is `lookup_any`.
|
| 23 |
-
|
| 24 |
-
That also means the subject-search work in `scimago_index` β the parsed
|
| 25 |
-
category/area sets, the tiered match levels, `with_level` β passes through
|
| 26 |
-
untouched, including its return types. This module has no opinion about it.
|
| 27 |
-
|
| 28 |
-
Fail-safe by design
|
| 29 |
-
-------------------
|
| 30 |
-
If journals.db is missing, unreadable or the wrong schema, every call falls
|
| 31 |
-
back to the CSV path and the Space behaves exactly as it does today. This
|
| 32 |
-
module never raises at import and never raises from a lookup.
|
| 33 |
-
|
| 34 |
-
What lookup_any() gains
|
| 35 |
-
-----------------------
|
| 36 |
-
Same keys, same types, same values app.py already reads β plus:
|
| 37 |
-
|
| 38 |
-
`quartiles_by_field` Every (subject area, quartile) pair, not just the best.
|
| 39 |
-
46% of ranked journals rank differently across their own
|
| 40 |
-
fields. `quartile` still returns the best one, so nothing
|
| 41 |
-
existing changes.
|
| 42 |
-
`state` / `warn` `warn` is True only for the 967 titles Scopus
|
| 43 |
-
discontinued for cause β never for the 6,855 renamed.
|
| 44 |
-
`successor_title` Where a renamed journal continued.
|
| 45 |
-
`oa_diamond` No APC and no subscription.
|
| 46 |
-
"""
|
| 47 |
-
from __future__ import annotations
|
| 48 |
-
|
| 49 |
-
import logging
|
| 50 |
-
import os
|
| 51 |
-
from typing import Optional
|
| 52 |
-
|
| 53 |
-
import scimago_index
|
| 54 |
-
|
| 55 |
-
logger = logging.getLogger(__name__)
|
| 56 |
-
|
| 57 |
-
_DB_PATH = os.path.join(os.path.dirname(__file__), "journals.db")
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
# ---------------------------------------------------------------------------
|
| 61 |
-
# Forward every name we do not define to scimago_index.
|
| 62 |
-
# ---------------------------------------------------------------------------
|
| 63 |
-
def __getattr__(name: str):
|
| 64 |
-
"""
|
| 65 |
-
PEP 562 module-level attribute hook. Anything app.py imports from here that
|
| 66 |
-
is not defined below is served from scimago_index unchanged β same object,
|
| 67 |
-
same signature, same behaviour. This is what makes the import swap safe
|
| 68 |
-
against future additions to scimago_index.
|
| 69 |
-
"""
|
| 70 |
-
try:
|
| 71 |
-
return getattr(scimago_index, name)
|
| 72 |
-
except AttributeError:
|
| 73 |
-
raise AttributeError(
|
| 74 |
-
f"module 'journal_lookup' has no attribute {name!r}, and neither "
|
| 75 |
-
f"does 'scimago_index'"
|
| 76 |
-
) from None
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def __dir__():
|
| 80 |
-
return sorted(set(globals()) | set(dir(scimago_index)))
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
_csv_lookup_any = scimago_index.lookup_any
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
# ---------------------------------------------------------------------------
|
| 87 |
-
# Index bootstrap β never allowed to raise
|
| 88 |
-
# ---------------------------------------------------------------------------
|
| 89 |
-
_index = None
|
| 90 |
-
_index_tried = False
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def _get_index():
|
| 94 |
-
global _index, _index_tried
|
| 95 |
-
if _index_tried:
|
| 96 |
-
return _index
|
| 97 |
-
_index_tried = True
|
| 98 |
-
try:
|
| 99 |
-
from researchbee_journals import JournalIndex
|
| 100 |
-
idx = JournalIndex(_DB_PATH)
|
| 101 |
-
meta = idx.meta()
|
| 102 |
-
logger.info(
|
| 103 |
-
"[JournalIndex] loaded %s journals (%s active, %s discontinued) built %s",
|
| 104 |
-
meta.get("n_journals"), meta.get("n_active"),
|
| 105 |
-
meta.get("n_discontinued"), meta.get("built_at", "")[:10],
|
| 106 |
-
)
|
| 107 |
-
print(f"[JournalIndex] loaded {meta.get('n_journals')} journals "
|
| 108 |
-
f"({meta.get('n_active')} active, {meta.get('n_discontinued')} "
|
| 109 |
-
f"discontinued) built {meta.get('built_at','')[:10]}", flush=True)
|
| 110 |
-
_index = idx
|
| 111 |
-
except Exception as e: # noqa: BLE001
|
| 112 |
-
logger.warning("[JournalIndex] unavailable (%s) β falling back to CSV", e)
|
| 113 |
-
print(f"[JournalIndex] UNAVAILABLE ({e}) β falling back to CSV", flush=True)
|
| 114 |
-
_index = None
|
| 115 |
-
return _index
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def index_available() -> bool:
|
| 119 |
-
return _get_index() is not None
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
# ---------------------------------------------------------------------------
|
| 123 |
-
# Shape translation
|
| 124 |
-
# ---------------------------------------------------------------------------
|
| 125 |
-
def _to_csv_shape(rec, matched_issn: str) -> dict:
|
| 126 |
-
"""
|
| 127 |
-
Render a JournalRecord in the dict shape app.py already consumes, then add
|
| 128 |
-
the new fields. The key names and value types below MUST match what
|
| 129 |
-
scimago_index.lookup_any returns β app.py reads .get("quartile"),
|
| 130 |
-
.get("rank"), .get("h_index"), .get("categories"), .get("publisher"),
|
| 131 |
-
.get("open_access"), .get("country"), .get("sourceid"), .get("matched_issn").
|
| 132 |
-
"""
|
| 133 |
-
# "Hematology (Q1); Oncology (Q1)" β the exact format the CSV produced.
|
| 134 |
-
cats = "; ".join(
|
| 135 |
-
f"{s['name']} ({s['quartile']})" if s.get("quartile") else s["name"]
|
| 136 |
-
for s in rec.subjects if s.get("name")
|
| 137 |
-
)
|
| 138 |
-
areas = "; ".join(sorted({s["parent"] for s in rec.subjects if s.get("parent")}))
|
| 139 |
-
|
| 140 |
-
return {
|
| 141 |
-
# ---- keys app.py already uses (same names, same types) ----
|
| 142 |
-
"title": rec.title or "",
|
| 143 |
-
"rank": str(rec.sjr_rank) if rec.sjr_rank else "",
|
| 144 |
-
"quartile": rec.sjr_best_quartile or "",
|
| 145 |
-
"h_index": str(rec.h_index) if rec.h_index is not None else "",
|
| 146 |
-
"categories": cats,
|
| 147 |
-
"areas": areas,
|
| 148 |
-
"publisher": rec.publisher_group or rec.publisher or "",
|
| 149 |
-
# SCImago's own OA flag first, for parity with the CSV path;
|
| 150 |
-
# Scopus/Unpaywall's flag as backup for titles SCImago does not rank.
|
| 151 |
-
"open_access": "Yes" if (rec.scimago_oa or rec.oa_diamond or rec.oa_unpaywall) else "No",
|
| 152 |
-
"country": rec.country or "",
|
| 153 |
-
"sourceid": rec.scimago_id or rec.scopus_id or "",
|
| 154 |
-
"matched_issn": matched_issn,
|
| 155 |
-
|
| 156 |
-
# ---- provenance, so the UI can say where each half came from ----
|
| 157 |
-
"from_scopus": True,
|
| 158 |
-
"from_scimago": rec.sjr is not None,
|
| 159 |
-
|
| 160 |
-
# ---- new keys; app.py can adopt these incrementally ----
|
| 161 |
-
"quartiles_by_field": [
|
| 162 |
-
{"code": s["asjc"], "field": s["name"], "quartile": s["quartile"]}
|
| 163 |
-
for s in rec.subjects if s.get("quartile")
|
| 164 |
-
],
|
| 165 |
-
"state": rec.state,
|
| 166 |
-
"state_message": rec.state_message,
|
| 167 |
-
"warn": rec.warn,
|
| 168 |
-
"currently_indexed": rec.currently_indexed,
|
| 169 |
-
"successor_title": rec.successor_title,
|
| 170 |
-
"oa_diamond": rec.oa_diamond,
|
| 171 |
-
"sjr": rec.sjr,
|
| 172 |
-
"coverage_start": rec.coverage_start,
|
| 173 |
-
"coverage_end": rec.coverage_end,
|
| 174 |
-
"source_type": rec.source_type,
|
| 175 |
-
"scopus_id": rec.scopus_id,
|
| 176 |
-
"medline": rec.medline,
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
def lookup_any(issns: list) -> Optional[dict]:
|
| 181 |
-
"""
|
| 182 |
-
Same contract as scimago_index.lookup_any: first ISSN that resolves wins,
|
| 183 |
-
None if none do.
|
| 184 |
-
"""
|
| 185 |
-
idx = _get_index()
|
| 186 |
-
if idx is None:
|
| 187 |
-
return _csv_lookup_any(issns)
|
| 188 |
-
try:
|
| 189 |
-
for issn in issns:
|
| 190 |
-
if not issn:
|
| 191 |
-
continue
|
| 192 |
-
rec = idx.lookup(issn=str(issn))
|
| 193 |
-
if rec:
|
| 194 |
-
from researchbee_journals import norm_issn
|
| 195 |
-
return _to_csv_shape(rec, norm_issn(str(issn)) or str(issn))
|
| 196 |
-
except Exception as e: # noqa: BLE001
|
| 197 |
-
logger.warning("[JournalIndex] lookup failed (%s) β CSV fallback", e)
|
| 198 |
-
return _csv_lookup_any(issns)
|
| 199 |
-
# A miss must still try the CSV. journals.db is NOT a strict superset:
|
| 200 |
-
# ~700 SCImago records (mostly conference series) have no matching Scopus
|
| 201 |
-
# source record, so returning None here would silently LOSE coverage the
|
| 202 |
-
# app has today.
|
| 203 |
-
result = _csv_lookup_any(issns)
|
| 204 |
-
if result is not None:
|
| 205 |
-
result.setdefault("from_scopus", False)
|
| 206 |
-
result.setdefault("from_scimago", True)
|
| 207 |
-
return result
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
def journal_detail(issn: str = "", title: str = "") -> Optional[dict]:
|
| 211 |
-
"""Richer lookup for one journal. None when the index is unavailable."""
|
| 212 |
-
idx = _get_index()
|
| 213 |
-
if idx is None:
|
| 214 |
-
return None
|
| 215 |
-
try:
|
| 216 |
-
rec = idx.lookup(issn=issn or None, title=title or None)
|
| 217 |
-
except Exception: # noqa: BLE001
|
| 218 |
-
return None
|
| 219 |
-
if not rec:
|
| 220 |
-
return None
|
| 221 |
-
d = _to_csv_shape(rec, rec.issn_e or rec.issn_print or "")
|
| 222 |
-
d["issn_print"] = rec.issn_print
|
| 223 |
-
d["issn_electronic"] = rec.issn_e
|
| 224 |
-
d["coverage_has_gap"] = rec.coverage_has_gap
|
| 225 |
return d
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Drop-in replacement for scimago_index, backed by journals.db.
|
| 3 |
+
|
| 4 |
+
Swap ONE import in app.py and nothing else:
|
| 5 |
+
|
| 6 |
+
- from scimago_index import (lookup_any, load_scimago, search_by_subject, ...)
|
| 7 |
+
+ from journal_lookup import (lookup_any, load_scimago, search_by_subject, ...)
|
| 8 |
+
|
| 9 |
+
Keep the import list exactly as it is β however long it is, and whatever gets
|
| 10 |
+
added to it later.
|
| 11 |
+
|
| 12 |
+
Why it forwards everything instead of naming functions
|
| 13 |
+
-------------------------------------------------------
|
| 14 |
+
The first version of this file re-exported four functions by name. That broke
|
| 15 |
+
the moment `scimago_index` grew `subject_vocabulary`, `is_real_category`,
|
| 16 |
+
`is_real_area` and `suggest_categories` β the import in app.py would have
|
| 17 |
+
raised ImportError at startup and taken the Space down.
|
| 18 |
+
|
| 19 |
+
So this module now forwards **every** attribute it does not define itself to
|
| 20 |
+
`scimago_index`, via PEP 562 module `__getattr__`. Add ten more functions to
|
| 21 |
+
`scimago_index` tomorrow and this file needs no change. The only name it
|
| 22 |
+
deliberately overrides is `lookup_any`.
|
| 23 |
+
|
| 24 |
+
That also means the subject-search work in `scimago_index` β the parsed
|
| 25 |
+
category/area sets, the tiered match levels, `with_level` β passes through
|
| 26 |
+
untouched, including its return types. This module has no opinion about it.
|
| 27 |
+
|
| 28 |
+
Fail-safe by design
|
| 29 |
+
-------------------
|
| 30 |
+
If journals.db is missing, unreadable or the wrong schema, every call falls
|
| 31 |
+
back to the CSV path and the Space behaves exactly as it does today. This
|
| 32 |
+
module never raises at import and never raises from a lookup.
|
| 33 |
+
|
| 34 |
+
What lookup_any() gains
|
| 35 |
+
-----------------------
|
| 36 |
+
Same keys, same types, same values app.py already reads β plus:
|
| 37 |
+
|
| 38 |
+
`quartiles_by_field` Every (subject area, quartile) pair, not just the best.
|
| 39 |
+
46% of ranked journals rank differently across their own
|
| 40 |
+
fields. `quartile` still returns the best one, so nothing
|
| 41 |
+
existing changes.
|
| 42 |
+
`state` / `warn` `warn` is True only for the 967 titles Scopus
|
| 43 |
+
discontinued for cause β never for the 6,855 renamed.
|
| 44 |
+
`successor_title` Where a renamed journal continued.
|
| 45 |
+
`oa_diamond` No APC and no subscription.
|
| 46 |
+
"""
|
| 47 |
+
from __future__ import annotations
|
| 48 |
+
|
| 49 |
+
import logging
|
| 50 |
+
import os
|
| 51 |
+
from typing import Optional
|
| 52 |
+
|
| 53 |
+
import scimago_index
|
| 54 |
+
|
| 55 |
+
logger = logging.getLogger(__name__)
|
| 56 |
+
|
| 57 |
+
_DB_PATH = os.path.join(os.path.dirname(__file__), "journals.db")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
# Forward every name we do not define to scimago_index.
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
def __getattr__(name: str):
|
| 64 |
+
"""
|
| 65 |
+
PEP 562 module-level attribute hook. Anything app.py imports from here that
|
| 66 |
+
is not defined below is served from scimago_index unchanged β same object,
|
| 67 |
+
same signature, same behaviour. This is what makes the import swap safe
|
| 68 |
+
against future additions to scimago_index.
|
| 69 |
+
"""
|
| 70 |
+
try:
|
| 71 |
+
return getattr(scimago_index, name)
|
| 72 |
+
except AttributeError:
|
| 73 |
+
raise AttributeError(
|
| 74 |
+
f"module 'journal_lookup' has no attribute {name!r}, and neither "
|
| 75 |
+
f"does 'scimago_index'"
|
| 76 |
+
) from None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def __dir__():
|
| 80 |
+
return sorted(set(globals()) | set(dir(scimago_index)))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
_csv_lookup_any = scimago_index.lookup_any
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
# Index bootstrap β never allowed to raise
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
_index = None
|
| 90 |
+
_index_tried = False
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _get_index():
|
| 94 |
+
global _index, _index_tried
|
| 95 |
+
if _index_tried:
|
| 96 |
+
return _index
|
| 97 |
+
_index_tried = True
|
| 98 |
+
try:
|
| 99 |
+
from researchbee_journals import JournalIndex
|
| 100 |
+
idx = JournalIndex(_DB_PATH)
|
| 101 |
+
meta = idx.meta()
|
| 102 |
+
logger.info(
|
| 103 |
+
"[JournalIndex] loaded %s journals (%s active, %s discontinued) built %s",
|
| 104 |
+
meta.get("n_journals"), meta.get("n_active"),
|
| 105 |
+
meta.get("n_discontinued"), meta.get("built_at", "")[:10],
|
| 106 |
+
)
|
| 107 |
+
print(f"[JournalIndex] loaded {meta.get('n_journals')} journals "
|
| 108 |
+
f"({meta.get('n_active')} active, {meta.get('n_discontinued')} "
|
| 109 |
+
f"discontinued) built {meta.get('built_at','')[:10]}", flush=True)
|
| 110 |
+
_index = idx
|
| 111 |
+
except Exception as e: # noqa: BLE001
|
| 112 |
+
logger.warning("[JournalIndex] unavailable (%s) β falling back to CSV", e)
|
| 113 |
+
print(f"[JournalIndex] UNAVAILABLE ({e}) β falling back to CSV", flush=True)
|
| 114 |
+
_index = None
|
| 115 |
+
return _index
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def index_available() -> bool:
|
| 119 |
+
return _get_index() is not None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
# Shape translation
|
| 124 |
+
# ---------------------------------------------------------------------------
|
| 125 |
+
def _to_csv_shape(rec, matched_issn: str) -> dict:
|
| 126 |
+
"""
|
| 127 |
+
Render a JournalRecord in the dict shape app.py already consumes, then add
|
| 128 |
+
the new fields. The key names and value types below MUST match what
|
| 129 |
+
scimago_index.lookup_any returns β app.py reads .get("quartile"),
|
| 130 |
+
.get("rank"), .get("h_index"), .get("categories"), .get("publisher"),
|
| 131 |
+
.get("open_access"), .get("country"), .get("sourceid"), .get("matched_issn").
|
| 132 |
+
"""
|
| 133 |
+
# "Hematology (Q1); Oncology (Q1)" β the exact format the CSV produced.
|
| 134 |
+
cats = "; ".join(
|
| 135 |
+
f"{s['name']} ({s['quartile']})" if s.get("quartile") else s["name"]
|
| 136 |
+
for s in rec.subjects if s.get("name")
|
| 137 |
+
)
|
| 138 |
+
areas = "; ".join(sorted({s["parent"] for s in rec.subjects if s.get("parent")}))
|
| 139 |
+
|
| 140 |
+
return {
|
| 141 |
+
# ---- keys app.py already uses (same names, same types) ----
|
| 142 |
+
"title": rec.title or "",
|
| 143 |
+
"rank": str(rec.sjr_rank) if rec.sjr_rank else "",
|
| 144 |
+
"quartile": rec.sjr_best_quartile or "",
|
| 145 |
+
"h_index": str(rec.h_index) if rec.h_index is not None else "",
|
| 146 |
+
"categories": cats,
|
| 147 |
+
"areas": areas,
|
| 148 |
+
"publisher": rec.publisher_group or rec.publisher or "",
|
| 149 |
+
# SCImago's own OA flag first, for parity with the CSV path;
|
| 150 |
+
# Scopus/Unpaywall's flag as backup for titles SCImago does not rank.
|
| 151 |
+
"open_access": "Yes" if (rec.scimago_oa or rec.oa_diamond or rec.oa_unpaywall) else "No",
|
| 152 |
+
"country": rec.country or "",
|
| 153 |
+
"sourceid": rec.scimago_id or rec.scopus_id or "",
|
| 154 |
+
"matched_issn": matched_issn,
|
| 155 |
+
|
| 156 |
+
# ---- provenance, so the UI can say where each half came from ----
|
| 157 |
+
"from_scopus": True,
|
| 158 |
+
"from_scimago": rec.sjr is not None,
|
| 159 |
+
|
| 160 |
+
# ---- new keys; app.py can adopt these incrementally ----
|
| 161 |
+
"quartiles_by_field": [
|
| 162 |
+
{"code": s["asjc"], "field": s["name"], "quartile": s["quartile"]}
|
| 163 |
+
for s in rec.subjects if s.get("quartile")
|
| 164 |
+
],
|
| 165 |
+
"state": rec.state,
|
| 166 |
+
"state_message": rec.state_message,
|
| 167 |
+
"warn": rec.warn,
|
| 168 |
+
"currently_indexed": rec.currently_indexed,
|
| 169 |
+
"successor_title": rec.successor_title,
|
| 170 |
+
"oa_diamond": rec.oa_diamond,
|
| 171 |
+
"sjr": rec.sjr,
|
| 172 |
+
"coverage_start": rec.coverage_start,
|
| 173 |
+
"coverage_end": rec.coverage_end,
|
| 174 |
+
"source_type": rec.source_type,
|
| 175 |
+
"scopus_id": rec.scopus_id,
|
| 176 |
+
"medline": rec.medline,
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def lookup_any(issns: list) -> Optional[dict]:
|
| 181 |
+
"""
|
| 182 |
+
Same contract as scimago_index.lookup_any: first ISSN that resolves wins,
|
| 183 |
+
None if none do.
|
| 184 |
+
"""
|
| 185 |
+
idx = _get_index()
|
| 186 |
+
if idx is None:
|
| 187 |
+
return _csv_lookup_any(issns)
|
| 188 |
+
try:
|
| 189 |
+
for issn in issns:
|
| 190 |
+
if not issn:
|
| 191 |
+
continue
|
| 192 |
+
rec = idx.lookup(issn=str(issn))
|
| 193 |
+
if rec:
|
| 194 |
+
from researchbee_journals import norm_issn
|
| 195 |
+
return _to_csv_shape(rec, norm_issn(str(issn)) or str(issn))
|
| 196 |
+
except Exception as e: # noqa: BLE001
|
| 197 |
+
logger.warning("[JournalIndex] lookup failed (%s) β CSV fallback", e)
|
| 198 |
+
return _csv_lookup_any(issns)
|
| 199 |
+
# A miss must still try the CSV. journals.db is NOT a strict superset:
|
| 200 |
+
# ~700 SCImago records (mostly conference series) have no matching Scopus
|
| 201 |
+
# source record, so returning None here would silently LOSE coverage the
|
| 202 |
+
# app has today.
|
| 203 |
+
result = _csv_lookup_any(issns)
|
| 204 |
+
if result is not None:
|
| 205 |
+
result.setdefault("from_scopus", False)
|
| 206 |
+
result.setdefault("from_scimago", True)
|
| 207 |
+
return result
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def journal_detail(issn: str = "", title: str = "") -> Optional[dict]:
|
| 211 |
+
"""Richer lookup for one journal. None when the index is unavailable."""
|
| 212 |
+
idx = _get_index()
|
| 213 |
+
if idx is None:
|
| 214 |
+
return None
|
| 215 |
+
try:
|
| 216 |
+
rec = idx.lookup(issn=issn or None, title=title or None)
|
| 217 |
+
except Exception: # noqa: BLE001
|
| 218 |
+
return None
|
| 219 |
+
if not rec:
|
| 220 |
+
return None
|
| 221 |
+
d = _to_csv_shape(rec, rec.issn_e or rec.issn_print or "")
|
| 222 |
+
d["issn_print"] = rec.issn_print
|
| 223 |
+
d["issn_electronic"] = rec.issn_e
|
| 224 |
+
d["coverage_has_gap"] = rec.coverage_has_gap
|
| 225 |
return d
|
khazna_loader.py
CHANGED
|
@@ -1,136 +1,136 @@
|
|
| 1 |
-
"""
|
| 2 |
-
khazna_loader.py β Space-side loading of the Khazna index.
|
| 3 |
-
|
| 4 |
-
The harvest runs in GitHub Actions (nightly, 23:00 Dubai) and publishes
|
| 5 |
-
khazna_index.json to an HF Dataset repo. The Space downloads it β it never
|
| 6 |
-
harvests, because HF Spaces have an ephemeral filesystem and a 20-minute
|
| 7 |
-
harvest would block startup.
|
| 8 |
-
|
| 9 |
-
Behaviour:
|
| 10 |
-
* startup -> download once, load into memory (non-blocking)
|
| 11 |
-
* daily 23:30 -> re-download (a few seconds)
|
| 12 |
-
* unavailable -> lookups return None, and the caller says "not checked"
|
| 13 |
-
rather than implying the article is absent from Khazna.
|
| 14 |
-
|
| 15 |
-
Add to requirements.txt:
|
| 16 |
-
huggingface_hub
|
| 17 |
-
apscheduler
|
| 18 |
-
"""
|
| 19 |
-
|
| 20 |
-
from __future__ import annotations
|
| 21 |
-
|
| 22 |
-
import asyncio
|
| 23 |
-
import json
|
| 24 |
-
import logging
|
| 25 |
-
import os
|
| 26 |
-
from pathlib import Path
|
| 27 |
-
from typing import Optional
|
| 28 |
-
|
| 29 |
-
logger = logging.getLogger("khazna")
|
| 30 |
-
|
| 31 |
-
HF_DATASET = os.getenv("KHAZNA_DATASET", "nikeshn/researchbee-data")
|
| 32 |
-
INDEX_FILE = "khazna_index.json"
|
| 33 |
-
LOCAL_PATH = Path("/tmp/khazna_index.json")
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
class KhaznaIndex:
|
| 37 |
-
def __init__(self) -> None:
|
| 38 |
-
self._idx: dict[str, dict] = {}
|
| 39 |
-
self._loaded_at: Optional[str] = None
|
| 40 |
-
|
| 41 |
-
# ββ loading ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
-
def _download(self) -> Optional[Path]:
|
| 43 |
-
try:
|
| 44 |
-
from huggingface_hub import hf_hub_download
|
| 45 |
-
p = hf_hub_download(
|
| 46 |
-
repo_id=HF_DATASET,
|
| 47 |
-
filename=INDEX_FILE,
|
| 48 |
-
repo_type="dataset",
|
| 49 |
-
token=os.getenv("HF_TOKEN"), # omit if the dataset is public
|
| 50 |
-
)
|
| 51 |
-
return Path(p)
|
| 52 |
-
except Exception as e:
|
| 53 |
-
logger.warning("Khazna index download failed: %s", e)
|
| 54 |
-
return LOCAL_PATH if LOCAL_PATH.exists() else None
|
| 55 |
-
|
| 56 |
-
def reload(self) -> bool:
|
| 57 |
-
path = self._download()
|
| 58 |
-
if not path or not path.exists():
|
| 59 |
-
logger.warning("Khazna index unavailable β lookups will return None")
|
| 60 |
-
return False
|
| 61 |
-
try:
|
| 62 |
-
data = json.loads(path.read_text(encoding="utf-8"))
|
| 63 |
-
except json.JSONDecodeError as e:
|
| 64 |
-
logger.error("Khazna index malformed, keeping previous copy: %s", e)
|
| 65 |
-
return False
|
| 66 |
-
# Never replace a good index with an empty one.
|
| 67 |
-
if not data and self._idx:
|
| 68 |
-
logger.error("Downloaded index was empty β keeping previous copy")
|
| 69 |
-
return False
|
| 70 |
-
self._idx = data
|
| 71 |
-
from datetime import datetime, timezone
|
| 72 |
-
self._loaded_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 73 |
-
logger.info("Khazna index loaded: %d DOIs", len(self._idx))
|
| 74 |
-
return True
|
| 75 |
-
|
| 76 |
-
# ββ lookup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 77 |
-
def get(self, doi: str) -> Optional[dict]:
|
| 78 |
-
d = (doi or "").strip().lower()
|
| 79 |
-
for p in ("https://doi.org/", "http://doi.org/", "https://dx.doi.org/", "doi:"):
|
| 80 |
-
if d.startswith(p):
|
| 81 |
-
d = d[len(p):]
|
| 82 |
-
break
|
| 83 |
-
return self._idx.get(d.strip("/"))
|
| 84 |
-
|
| 85 |
-
@property
|
| 86 |
-
def available(self) -> bool:
|
| 87 |
-
return bool(self._idx)
|
| 88 |
-
|
| 89 |
-
@property
|
| 90 |
-
def status(self) -> dict:
|
| 91 |
-
return {"available": self.available,
|
| 92 |
-
"records": len(self._idx),
|
| 93 |
-
"loaded_at": self._loaded_at,
|
| 94 |
-
"source": HF_DATASET}
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
khazna_index = KhaznaIndex()
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
# ββ FastAPI wiring (app.py) ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 101 |
-
#
|
| 102 |
-
# from khazna_loader import khazna_index
|
| 103 |
-
# from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
| 104 |
-
# from apscheduler.triggers.cron import CronTrigger
|
| 105 |
-
#
|
| 106 |
-
# @app.on_event("startup")
|
| 107 |
-
# async def _startup():
|
| 108 |
-
# # Non-blocking: the Space serves traffic immediately; Khazna lookups
|
| 109 |
-
# # report "not checked" for the few seconds before the index lands.
|
| 110 |
-
# asyncio.create_task(asyncio.to_thread(khazna_index.reload))
|
| 111 |
-
#
|
| 112 |
-
# sched = AsyncIOScheduler(timezone="Asia/Dubai")
|
| 113 |
-
# sched.add_job(lambda: asyncio.create_task(asyncio.to_thread(khazna_index.reload)),
|
| 114 |
-
# CronTrigger(hour=23, minute=30)) # 30 min after the harvest
|
| 115 |
-
# sched.start()
|
| 116 |
-
#
|
| 117 |
-
# @app.get("/api/health/khazna")
|
| 118 |
-
# async def khazna_health():
|
| 119 |
-
# return khazna_index.status
|
| 120 |
-
#
|
| 121 |
-
#
|
| 122 |
-
# ββ Using it in the license flow βββββββββββββββββββββββββββββββββββββββββββ
|
| 123 |
-
#
|
| 124 |
-
# def khazna_status_for(doi: str) -> dict:
|
| 125 |
-
# """Three-valued, never guesses absence when the index is missing."""
|
| 126 |
-
# if not khazna_index.available:
|
| 127 |
-
# return {"checked": False,
|
| 128 |
-
# "note": "Khazna could not be checked right now."}
|
| 129 |
-
# rec = khazna_index.get(doi)
|
| 130 |
-
# if rec is None:
|
| 131 |
-
# return {"checked": True, "in_khazna": False}
|
| 132 |
-
# return {"checked": True, "in_khazna": True,
|
| 133 |
-
# "deposit_state": rec["deposit_state"], # open|embargoed|...|metadata_only
|
| 134 |
-
# "embargo_end": rec.get("embargo_end"),
|
| 135 |
-
# "portal_url": rec.get("portal_url"),
|
| 136 |
-
# "needs_deposit": rec["deposit_state"] in ("metadata_only", "closed")}
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
khazna_loader.py β Space-side loading of the Khazna index.
|
| 3 |
+
|
| 4 |
+
The harvest runs in GitHub Actions (nightly, 23:00 Dubai) and publishes
|
| 5 |
+
khazna_index.json to an HF Dataset repo. The Space downloads it β it never
|
| 6 |
+
harvests, because HF Spaces have an ephemeral filesystem and a 20-minute
|
| 7 |
+
harvest would block startup.
|
| 8 |
+
|
| 9 |
+
Behaviour:
|
| 10 |
+
* startup -> download once, load into memory (non-blocking)
|
| 11 |
+
* daily 23:30 -> re-download (a few seconds)
|
| 12 |
+
* unavailable -> lookups return None, and the caller says "not checked"
|
| 13 |
+
rather than implying the article is absent from Khazna.
|
| 14 |
+
|
| 15 |
+
Add to requirements.txt:
|
| 16 |
+
huggingface_hub
|
| 17 |
+
apscheduler
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import asyncio
|
| 23 |
+
import json
|
| 24 |
+
import logging
|
| 25 |
+
import os
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
from typing import Optional
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger("khazna")
|
| 30 |
+
|
| 31 |
+
HF_DATASET = os.getenv("KHAZNA_DATASET", "nikeshn/researchbee-data")
|
| 32 |
+
INDEX_FILE = "khazna_index.json"
|
| 33 |
+
LOCAL_PATH = Path("/tmp/khazna_index.json")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class KhaznaIndex:
|
| 37 |
+
def __init__(self) -> None:
|
| 38 |
+
self._idx: dict[str, dict] = {}
|
| 39 |
+
self._loaded_at: Optional[str] = None
|
| 40 |
+
|
| 41 |
+
# ββ loading ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
def _download(self) -> Optional[Path]:
|
| 43 |
+
try:
|
| 44 |
+
from huggingface_hub import hf_hub_download
|
| 45 |
+
p = hf_hub_download(
|
| 46 |
+
repo_id=HF_DATASET,
|
| 47 |
+
filename=INDEX_FILE,
|
| 48 |
+
repo_type="dataset",
|
| 49 |
+
token=os.getenv("HF_TOKEN"), # omit if the dataset is public
|
| 50 |
+
)
|
| 51 |
+
return Path(p)
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.warning("Khazna index download failed: %s", e)
|
| 54 |
+
return LOCAL_PATH if LOCAL_PATH.exists() else None
|
| 55 |
+
|
| 56 |
+
def reload(self) -> bool:
|
| 57 |
+
path = self._download()
|
| 58 |
+
if not path or not path.exists():
|
| 59 |
+
logger.warning("Khazna index unavailable β lookups will return None")
|
| 60 |
+
return False
|
| 61 |
+
try:
|
| 62 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 63 |
+
except json.JSONDecodeError as e:
|
| 64 |
+
logger.error("Khazna index malformed, keeping previous copy: %s", e)
|
| 65 |
+
return False
|
| 66 |
+
# Never replace a good index with an empty one.
|
| 67 |
+
if not data and self._idx:
|
| 68 |
+
logger.error("Downloaded index was empty β keeping previous copy")
|
| 69 |
+
return False
|
| 70 |
+
self._idx = data
|
| 71 |
+
from datetime import datetime, timezone
|
| 72 |
+
self._loaded_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 73 |
+
logger.info("Khazna index loaded: %d DOIs", len(self._idx))
|
| 74 |
+
return True
|
| 75 |
+
|
| 76 |
+
# ββ lookup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 77 |
+
def get(self, doi: str) -> Optional[dict]:
|
| 78 |
+
d = (doi or "").strip().lower()
|
| 79 |
+
for p in ("https://doi.org/", "http://doi.org/", "https://dx.doi.org/", "doi:"):
|
| 80 |
+
if d.startswith(p):
|
| 81 |
+
d = d[len(p):]
|
| 82 |
+
break
|
| 83 |
+
return self._idx.get(d.strip("/"))
|
| 84 |
+
|
| 85 |
+
@property
|
| 86 |
+
def available(self) -> bool:
|
| 87 |
+
return bool(self._idx)
|
| 88 |
+
|
| 89 |
+
@property
|
| 90 |
+
def status(self) -> dict:
|
| 91 |
+
return {"available": self.available,
|
| 92 |
+
"records": len(self._idx),
|
| 93 |
+
"loaded_at": self._loaded_at,
|
| 94 |
+
"source": HF_DATASET}
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
khazna_index = KhaznaIndex()
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ββ FastAPI wiring (app.py) ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 101 |
+
#
|
| 102 |
+
# from khazna_loader import khazna_index
|
| 103 |
+
# from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
| 104 |
+
# from apscheduler.triggers.cron import CronTrigger
|
| 105 |
+
#
|
| 106 |
+
# @app.on_event("startup")
|
| 107 |
+
# async def _startup():
|
| 108 |
+
# # Non-blocking: the Space serves traffic immediately; Khazna lookups
|
| 109 |
+
# # report "not checked" for the few seconds before the index lands.
|
| 110 |
+
# asyncio.create_task(asyncio.to_thread(khazna_index.reload))
|
| 111 |
+
#
|
| 112 |
+
# sched = AsyncIOScheduler(timezone="Asia/Dubai")
|
| 113 |
+
# sched.add_job(lambda: asyncio.create_task(asyncio.to_thread(khazna_index.reload)),
|
| 114 |
+
# CronTrigger(hour=23, minute=30)) # 30 min after the harvest
|
| 115 |
+
# sched.start()
|
| 116 |
+
#
|
| 117 |
+
# @app.get("/api/health/khazna")
|
| 118 |
+
# async def khazna_health():
|
| 119 |
+
# return khazna_index.status
|
| 120 |
+
#
|
| 121 |
+
#
|
| 122 |
+
# ββ Using it in the license flow βββββββββββββββββββββββββββββββββββββββββββ
|
| 123 |
+
#
|
| 124 |
+
# def khazna_status_for(doi: str) -> dict:
|
| 125 |
+
# """Three-valued, never guesses absence when the index is missing."""
|
| 126 |
+
# if not khazna_index.available:
|
| 127 |
+
# return {"checked": False,
|
| 128 |
+
# "note": "Khazna could not be checked right now."}
|
| 129 |
+
# rec = khazna_index.get(doi)
|
| 130 |
+
# if rec is None:
|
| 131 |
+
# return {"checked": True, "in_khazna": False}
|
| 132 |
+
# return {"checked": True, "in_khazna": True,
|
| 133 |
+
# "deposit_state": rec["deposit_state"], # open|embargoed|...|metadata_only
|
| 134 |
+
# "embargo_end": rec.get("embargo_end"),
|
| 135 |
+
# "portal_url": rec.get("portal_url"),
|
| 136 |
+
# "needs_deposit": rec["deposit_state"] in ("metadata_only", "closed")}
|
ku_knowledge.py
CHANGED
|
@@ -1,369 +1,369 @@
|
|
| 1 |
-
"""
|
| 2 |
-
KU Open Access Knowledge Base
|
| 3 |
-
Extracted from: https://library.ku.ac.ae/oa (all tabs)
|
| 4 |
-
Last extracted: May 2026
|
| 5 |
-
Loaded once at startup and injected into CHAT_SYSTEM_PROMPT.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
KU_OA_KNOWLEDGE = """
|
| 9 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 10 |
-
KU LIBRARY β OPEN ACCESS PUBLISHING KNOWLEDGE BASE
|
| 11 |
-
Source: https://library.ku.ac.ae/oa | Extracted May 2026
|
| 12 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 13 |
-
|
| 14 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 15 |
-
1. WHAT IS OPEN ACCESS?
|
| 16 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
-
Open Access (OA) publishing means published work can be accessed
|
| 18 |
-
by readers without a subscription or institutional access.
|
| 19 |
-
Because readers do not pay, publishers generate revenue by charging
|
| 20 |
-
the author an Article Processing Charge (APC).
|
| 21 |
-
|
| 22 |
-
KU supports three OA models:
|
| 23 |
-
|
| 24 |
-
GOLD OA:
|
| 25 |
-
- Journal is completely Open Access
|
| 26 |
-
- Publisher charges an APC to the author/institution
|
| 27 |
-
- All articles in the journal are freely available
|
| 28 |
-
|
| 29 |
-
HYBRID:
|
| 30 |
-
- Journal has a mix of OA and closed-access articles
|
| 31 |
-
- OA articles have an APC; traditional articles do not
|
| 32 |
-
- Author chooses at submission whether to go OA
|
| 33 |
-
|
| 34 |
-
GREEN OA:
|
| 35 |
-
- Author/institution publishes a copy in an institutional repository
|
| 36 |
-
- The repository copy is freely accessible even if the journal version is not
|
| 37 |
-
- KU's institutional repository is Khazna (khazna.ku.ac.ae)
|
| 38 |
-
|
| 39 |
-
Important: OA does not change the peer-review process. Articles must
|
| 40 |
-
still be accepted and pass peer review before publication.
|
| 41 |
-
|
| 42 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
-
2. KU OPEN ACCESS POLICY
|
| 44 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
-
KU administration is committed to encouraging and supporting faculty
|
| 46 |
-
to use Open Access publishing for their scholarly work.
|
| 47 |
-
|
| 48 |
-
Full policy document:
|
| 49 |
-
https://portal.ku.ac.ae/Policy/KU%20Poliy%20and%20Procedure%20Library/LIB%208540%20Open%20Access%20Publishing.pdf
|
| 50 |
-
|
| 51 |
-
OA contact email: openaccess@ku.ac.ae
|
| 52 |
-
OA guide: https://library.ku.ac.ae/oa/
|
| 53 |
-
|
| 54 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
-
3. KU OA FUNDING OPTIONS β THREE TIERS
|
| 56 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
-
|
| 58 |
-
TIER 1 β TOP 15% JOURNALS (Library pays APC only):
|
| 59 |
-
Conditions (ALL three must be met):
|
| 60 |
-
- At least one author's PRIMARY affiliation on the paper must be KU
|
| 61 |
-
- Paper must be in a journal from one of KU's 6 partner publishers:
|
| 62 |
-
ACS, Elsevier, IEEE, Springer Nature, Taylor & Francis, Wiley
|
| 63 |
-
- Journal must be rated in the TOP 15% by Scopus
|
| 64 |
-
|
| 65 |
-
Publisher-specific OA process pages:
|
| 66 |
-
- ACS: https://library.ku.ac.ae/oa/acs
|
| 67 |
-
- IEEE: https://library.ku.ac.ae/oa/ieee
|
| 68 |
-
- Elsevier: https://library.ku.ac.ae/oa/elsevier
|
| 69 |
-
- Springer Nature:https://library.ku.ac.ae/oa/SpringerNature
|
| 70 |
-
- Taylor & Francis:https://library.ku.ac.ae/oa/TandF
|
| 71 |
-
- Wiley: https://library.ku.ac.ae/oa/wiley
|
| 72 |
-
- Check ratings: https://library.ku.ac.ae/oa/ratings
|
| 73 |
-
|
| 74 |
-
What is covered: Full basic APC cost
|
| 75 |
-
What is NOT covered: Overlength page charges, colour page charges,
|
| 76 |
-
or any charges beyond the basic APC
|
| 77 |
-
|
| 78 |
-
How to apply: Process is handled automatically through the publisher
|
| 79 |
-
portal when the author selects KU as their institution.
|
| 80 |
-
|
| 81 |
-
Contact: openaccess@ku.ac.ae
|
| 82 |
-
|
| 83 |
-
TIER 2 β TOP 10% JOURNALS (APC + EPC via Provost office):
|
| 84 |
-
Conditions:
|
| 85 |
-
- Paper accepted in journal rated TOP 10% by Scopus
|
| 86 |
-
- KU must be author's primary affiliation
|
| 87 |
-
- Must have received email from library confirming they don't
|
| 88 |
-
cover these charges (i.e. not in Tier 1 agreement)
|
| 89 |
-
|
| 90 |
-
How to apply:
|
| 91 |
-
- Submit Payment Request (PMR) through KU Portal:
|
| 92 |
-
https://kuconnect.ku.ac.ae/group/guest/services#/service-details/831545
|
| 93 |
-
- Attach: Invoice + Final accepted manuscript + Library rejection email
|
| 94 |
-
|
| 95 |
-
Important notes:
|
| 96 |
-
- This is NOT a guarantee of approval β Provost office reviews each case
|
| 97 |
-
- Payments completed by finance team (their SLAs not in Provost control)
|
| 98 |
-
- Direct PMR only β expense reimbursement is NOT allowed
|
| 99 |
-
|
| 100 |
-
TIER 3 β Q1 JOURNALS (Department budget):
|
| 101 |
-
Conditions:
|
| 102 |
-
- Neither Tier 1 nor Tier 2 applies
|
| 103 |
-
- Paper published in a Q1 journal (Scopus-determined)
|
| 104 |
-
|
| 105 |
-
How to apply:
|
| 106 |
-
- Contact your department head to inquire about department budget support
|
| 107 |
-
- Direct PMR only β expense reimbursement is NOT allowed
|
| 108 |
-
|
| 109 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 110 |
-
4. HOW KU DETERMINES JOURNAL RATINGS (TOP 15%)
|
| 111 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 112 |
-
KU uses Scopus to determine journal ratings.
|
| 113 |
-
- Ratings page: https://library.ku.ac.ae/oa/ratings
|
| 114 |
-
|
| 115 |
-
Guidelines history:
|
| 116 |
-
- From 24 October 2023: journals must be in top 20% by Scopus
|
| 117 |
-
- From 1 January 2024 onwards: journals must be in top 15% by Scopus
|
| 118 |
-
(current requirement)
|
| 119 |
-
|
| 120 |
-
How to interpret Scopus scores:
|
| 121 |
-
https://service-elsevier-com.libconnect.ku.ac.ae/app/answers/detail/a_id/14834/supporthub/scopus/
|
| 122 |
-
|
| 123 |
-
ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 124 |
-
5. PUBLISHER-SPECIFIC PROCESSES
|
| 125 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
-
|
| 127 |
-
ββ WILEY ββββββββββββββββββββββββββββββββββββββββββ
|
| 128 |
-
Agreement: Active. Covers Gold OA and Hybrid journals.
|
| 129 |
-
Covered journals list (top 15%):
|
| 130 |
-
Excel: https://library.ku.ac.ae/ld.php?content_id=84913015
|
| 131 |
-
PDF: https://library.ku.ac.ae/ld.php?content_id=84913016
|
| 132 |
-
|
| 133 |
-
Submission process:
|
| 134 |
-
1. Author submits article via Wiley Author Center
|
| 135 |
-
2. When asked who pays APC, select:
|
| 136 |
-
"My institution or funder has an agreement with Wiley and may pay"
|
| 137 |
-
3. Select Khalifa University using the institutional lookup tool
|
| 138 |
-
4. APC administrators notified β approved IF journal is top 15% (Scopus)
|
| 139 |
-
5. Paper goes through editing and peer review
|
| 140 |
-
6. If accepted, author acknowledges licence agreement
|
| 141 |
-
7. Paper published
|
| 142 |
-
|
| 143 |
-
Wiley Author Portal: http://www.wileyauthors.com/
|
| 144 |
-
OA resources: https://www.wiley.com/network/researchers/licensing-and-open-access
|
| 145 |
-
|
| 146 |
-
ββ ELSEVIER ββββββββββββββββββββββββββββββββββββββ
|
| 147 |
-
Agreement: Active. Covers Gold OA and Hybrid journals.
|
| 148 |
-
CRITICAL: Corresponding author MUST be affiliated with KU.
|
| 149 |
-
If corresponding author is from another university, that university
|
| 150 |
-
or the authors must pay the APC β KU cannot cover it.
|
| 151 |
-
|
| 152 |
-
Covered journals list (top 5%):
|
| 153 |
-
Excel: https://library.ku.ac.ae/ld.php?content_id=84910855
|
| 154 |
-
PDF: https://library.ku.ac.ae/ld.php?content_id=84910856
|
| 155 |
-
Note: Elsevier list covers top 5% (stricter than other publishers).
|
| 156 |
-
|
| 157 |
-
Submission process:
|
| 158 |
-
1. After acceptance, corresponding author receives email with link
|
| 159 |
-
2. Click "Rights and Access" form link
|
| 160 |
-
3. Corresponding author selects KU as affiliation
|
| 161 |
-
4. Add co-authors and their affiliations
|
| 162 |
-
5. Optionally add funder details
|
| 163 |
-
6. Choose Gold Open Access or Subscription
|
| 164 |
-
7. If OA chosen, select CC licence
|
| 165 |
-
8. Accept agreement and submit
|
| 166 |
-
9. KU administrators notified directly to pay the APC
|
| 167 |
-
|
| 168 |
-
ββ SPRINGER NATURE ββββββββββββββββββββββββββββββββ
|
| 169 |
-
Agreement: Active from agreement date. Covers HYBRID journals only.
|
| 170 |
-
IMPORTANT EXCLUSIONS:
|
| 171 |
-
- Agreement covers hybrid journals ONLY β NOT pure Gold OA journals
|
| 172 |
-
- Scientific Reports is specifically EXCLUDED from the agreement
|
| 173 |
-
(KU cannot pay APCs for Scientific Reports)
|
| 174 |
-
- Corresponding author MUST be affiliated with KU
|
| 175 |
-
|
| 176 |
-
Covered journals list (top 15%):
|
| 177 |
-
Excel: https://library.ku.ac.ae/ld.php?content_id=84909217
|
| 178 |
-
PDF: https://library.ku.ac.ae/ld.php?content_id=84909223
|
| 179 |
-
|
| 180 |
-
Submission process:
|
| 181 |
-
1. After editorial acceptance, corresponding author receives form
|
| 182 |
-
2. Confirm country
|
| 183 |
-
3. Confirm institutional affiliation (select KU)
|
| 184 |
-
4. Publisher checks eligibility β confirmation given if eligible
|
| 185 |
-
5. Author submits
|
| 186 |
-
6. KU staff notified
|
| 187 |
-
7. If journal meets KU guidelines β APC approved and paid
|
| 188 |
-
8. If journal does NOT meet guidelines β request rejected,
|
| 189 |
-
authors must arrange alternative payment
|
| 190 |
-
|
| 191 |
-
ββ TAYLOR & FRANCIS βββββββββββββββββββββββββββββββ
|
| 192 |
-
Agreement: Active from 1 January 2023.
|
| 193 |
-
Corresponding author MUST be affiliated with KU and use KU email
|
| 194 |
-
address β this allows publisher to automatically request APC from KU.
|
| 195 |
-
If corresponding author not affiliated with KU, KU cannot pay.
|
| 196 |
-
|
| 197 |
-
Note: Journal list currently being updated to reflect newest Scopus ratings.
|
| 198 |
-
|
| 199 |
-
Helpful T&F author resources:
|
| 200 |
-
- Why OA: https://authorservices.taylorandfrancis.com/choose-open/publishing-open-access/
|
| 201 |
-
- Writing advice: https://authorservices.taylorandfrancis.com/publishing-your-research/writing-your-paper/
|
| 202 |
-
- Submission info: https://authorservices.taylorandfrancis.com/publishing-your-research/making-your-submission/
|
| 203 |
-
|
| 204 |
-
ββ ACS (American Chemical Society) ββββββββββββββββ
|
| 205 |
-
Agreement: Active. 42 journals meet KU guidelines.
|
| 206 |
-
NOTE: ACS Omega is NOT rated in top 15% and is NOT eligible for KU OA payment.
|
| 207 |
-
|
| 208 |
-
Covered journals list (top 15%):
|
| 209 |
-
Excel: https://library.ku.ac.ae/ld.php?content_id=75321482
|
| 210 |
-
PDF: https://library.ku.ac.ae/ld.php?content_id=75321479
|
| 211 |
-
|
| 212 |
-
Submission process:
|
| 213 |
-
1. Submit article via ACS Publication Center: https://publish.acs.org/publish/
|
| 214 |
-
2. When asked who pays APC, select that the university may pay
|
| 215 |
-
3. Select Khalifa University using institutional lookup
|
| 216 |
-
4. APC administrators notified
|
| 217 |
-
- If journal is top 15% (Scopus) β APC approved
|
| 218 |
-
- If NOT top 15% β funding request rejected
|
| 219 |
-
5. Paper goes through editing and peer review
|
| 220 |
-
6. If accepted, author acknowledges licence agreement
|
| 221 |
-
7. Paper published
|
| 222 |
-
|
| 223 |
-
ACS tools: https://publish.acs.org/publish/
|
| 224 |
-
ACS OA info: https://acsopenaccess.org/
|
| 225 |
-
|
| 226 |
-
β
|
| 227 |
-
Agreement: Active. KU has agreement with IEEE.
|
| 228 |
-
Contact openaccess@ku.ac.ae for current IEEE journal list and process details.
|
| 229 |
-
IEEE journal list: https://library.ku.ac.ae/oa/ieee
|
| 230 |
-
|
| 231 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 232 |
-
6. KHAZNA β KU INSTITUTIONAL REPOSITORY
|
| 233 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 234 |
-
URL: https://khazna.ku.ac.ae
|
| 235 |
-
Contact: khazna@ku.ac.ae
|
| 236 |
-
|
| 237 |
-
Khazna is KU's institutional repository (Green OA).
|
| 238 |
-
- All KU published articles should be deposited in Khazna
|
| 239 |
-
- Even under embargo, deposit the metadata record immediately
|
| 240 |
-
- Full text deposited once embargo expires
|
| 241 |
-
- Deposit increases visibility of KU research globally
|
| 242 |
-
- Supports KU OA compliance
|
| 243 |
-
|
| 244 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 245 |
-
7. RESEARCHBEE TOOLS β WHAT EACH DOES
|
| 246 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 247 |
-
ResearchBee has THREE tools available as tabs on the main page:
|
| 248 |
-
|
| 249 |
-
TOOL 1 β JOURNAL SUBMISSION TAB:
|
| 250 |
-
Two modes:
|
| 251 |
-
a) Browse by Subject: Type a discipline β get top 15 ranked journals
|
| 252 |
-
instantly from 53,000+ indexed titles. No manuscript needed.
|
| 253 |
-
b) Analyse Manuscript: Paste abstract β AI matches journals with full
|
| 254 |
-
Green OA policy breakdown, submission strategy, cover letter generation.
|
| 255 |
-
Use this for: finding where to publish, journal rankings, OA compliance.
|
| 256 |
-
|
| 257 |
-
TOOL 2 β LICENSE CHECKING TAB:
|
| 258 |
-
Look up self-archiving rights for a specific known journal.
|
| 259 |
-
Gives: version-specific permissions (preprint/AAM/VoR), embargo periods,
|
| 260 |
-
licence conditions, repository restrictions.
|
| 261 |
-
Use this for: "Can I deposit my accepted manuscript in Khazna?"
|
| 262 |
-
or "What's the embargo on this journal?"
|
| 263 |
-
|
| 264 |
-
TOOL 3 β DATA REPOSITORY TAB:
|
| 265 |
-
Describe a dataset β get matched repositories (domain-specific +
|
| 266 |
-
generalist like Zenodo, Dryad) + Khazna.
|
| 267 |
-
Gives: FAIR alignment, licence support, embargo support, cost, DOI provision.
|
| 268 |
-
Use this for: "Where should I deposit my research data?"
|
| 269 |
-
|
| 270 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 271 |
-
8. KEY CONTACTS & SUPPORT
|
| 272 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 273 |
-
OA Services: openaccess@ku.ac.ae
|
| 274 |
-
Khazna Repository: khazna@ku.ac.ae
|
| 275 |
-
Library general: library@ku.ac.ae
|
| 276 |
-
KU Library: https://library.ku.ac.ae/lib
|
| 277 |
-
OA Guide: https://library.ku.ac.ae/oa
|
| 278 |
-
|
| 279 |
-
RESEARCH SUPPORT β PRIMARY CONTACT:
|
| 280 |
-
Nikesh Narayanan, Research Librarian
|
| 281 |
-
Email: nikesh.narayanan@ku.ac.ae
|
| 282 |
-
For: journal selection advice, OA publishing support, research data management,
|
| 283 |
-
data management plans, repository selection, FAIR data guidance,
|
| 284 |
-
any publishing or research support question not answered by the tools.
|
| 285 |
-
|
| 286 |
-
PUBLISHER RECOMMENDATION FORM:
|
| 287 |
-
URL: https://kustar.libwizard.com/f/oapub
|
| 288 |
-
Purpose: Use this form to suggest a publisher or journal to be added to
|
| 289 |
-
KU Library's OA APC agreement. If a researcher wants KU to negotiate
|
| 290 |
-
an APC agreement with a publisher not currently covered, they submit
|
| 291 |
-
this form. The library reviews all suggestions.
|
| 292 |
-
|
| 293 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 294 |
-
8b. KU JOURNAL FINDER TOOLS GUIDE
|
| 295 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 296 |
-
KU Library maintains a comprehensive guide to journal finder tools:
|
| 297 |
-
URL: https://library.ku.ac.ae/journalfinder
|
| 298 |
-
Created by: Nikesh Narayanan, Research Librarian
|
| 299 |
-
|
| 300 |
-
The guide covers:
|
| 301 |
-
- ResearchBee (KU's own AI tool β recommended starting point)
|
| 302 |
-
- Cross-publisher AI matchers: EndNote Find a Journal, Trinka, JournalGuide, Paperpile, JANE, Editage
|
| 303 |
-
- Publisher-specific finders: Elsevier, Springer Nature, Wiley, Taylor & Francis, IEEE, SAGE, Emerald, MDPI
|
| 304 |
-
- Indexing & verification tools: Scopus Sources, WoS Master Journal List, DOAJ, SCImago, Think.Check.Submit
|
| 305 |
-
- Open access journal finders: Enago OA Journal Finder, DOAJ, PLOS
|
| 306 |
-
|
| 307 |
-
When to direct users there:
|
| 308 |
-
- "What tools can I use to find a journal?"
|
| 309 |
-
- "How do I choose the right journal for my paper?"
|
| 310 |
-
- "Are there other journal recommendation tools besides ResearchBee?"
|
| 311 |
-
- "Which Elsevier/Wiley/Springer journal should I target?"
|
| 312 |
-
- "How do I check if a journal is indexed?"
|
| 313 |
-
- "Is this journal trustworthy / predatory?"
|
| 314 |
-
|
| 315 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 316 |
-
9. RESEARCH ANALYTICS TOOLS (for citation & performance analysis)
|
| 317 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 318 |
-
For deep citation analysis, journal impact metrics, author h-index,
|
| 319 |
-
research performance, and institutional analytics β direct researchers
|
| 320 |
-
to these tools (all accessible via KU Library proxy):
|
| 321 |
-
|
| 322 |
-
SCOPUS (Elsevier):
|
| 323 |
-
Access: http://login.khalifa.idm.oclc.org/login?url=https://www.scopus.com
|
| 324 |
-
Best for: Journal rankings, citation counts, author profiles, h-index,
|
| 325 |
-
journal source metrics (CiteScore, SJR, SNIP)
|
| 326 |
-
|
| 327 |
-
WEB OF SCIENCE (Clarivate):
|
| 328 |
-
Access: http://login.khalifa.idm.oclc.org/login?url=https://www.webofscience.com
|
| 329 |
-
Best for: Citation reports, impact factor, Journal Citation Reports (JCR),
|
| 330 |
-
researcher profiles, highly cited papers
|
| 331 |
-
|
| 332 |
-
SCIVAL (Elsevier):
|
| 333 |
-
Access: http://login.khalifa.idm.oclc.org/login?url=https://www.scival.com
|
| 334 |
-
Best for: Institutional research performance, benchmarking KU against
|
| 335 |
-
other institutions, field-weighted citation impact (FWCI),
|
| 336 |
-
collaboration networks, research trends by topic
|
| 337 |
-
|
| 338 |
-
When to direct users there:
|
| 339 |
-
- "What is my h-index?" β Scopus or Web of Science
|
| 340 |
-
- "How does KU compare to other UAE universities?" β SciVal
|
| 341 |
-
- "What is the impact factor of this journal?" β Web of Science JCR
|
| 342 |
-
- "Show me citation trends for my field" β Scopus or SciVal
|
| 343 |
-
- "Who are the top authors in my field?" β Scopus
|
| 344 |
-
- "Detailed journal metrics beyond what ResearchBee shows" β Scopus Sources
|
| 345 |
-
|
| 346 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 347 |
-
10. APPROVED LINKS (chatbot may only use these)
|
| 348 |
-
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 349 |
-
- KU Journal Finder Guide: https://library.ku.ac.ae/journalfinder
|
| 350 |
-
- KU OA guide: https://library.ku.ac.ae/oa
|
| 351 |
-
- KU Library: https://library.ku.ac.ae/lib
|
| 352 |
-
- Khazna repository: https://khazna.ku.ac.ae
|
| 353 |
-
- OA policy document: https://portal.ku.ac.ae/Policy/KU%20Poliy%20and%20Procedure%20Library/LIB%208540%20Open%20Access%20Publishing.pdf
|
| 354 |
-
- Publisher recommendation: https://kustar.libwizard.com/f/oapub
|
| 355 |
-
- Open Policy Finder: https://openpolicyfinder.jisc.ac.uk
|
| 356 |
-
- re3data (repositories): https://www.re3data.org
|
| 357 |
-
- FAIRsharing: https://fairsharing.org
|
| 358 |
-
- Research Librarian: nikesh.narayanan@ku.ac.ae
|
| 359 |
-
- OA services email: openaccess@ku.ac.ae
|
| 360 |
-
- Khazna contact: khazna@ku.ac.ae
|
| 361 |
-
- Library email: library@ku.ac.ae
|
| 362 |
-
- Scopus (KU proxy): http://login.khalifa.idm.oclc.org/login?url=https://www.scopus.com
|
| 363 |
-
- Web of Science (KU proxy):http://login.khalifa.idm.oclc.org/login?url=https://www.webofscience.com
|
| 364 |
-
- SciVal (KU proxy): http://login.khalifa.idm.oclc.org/login?url=https://www.scival.com
|
| 365 |
-
""".strip()
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
def get_knowledge() -> str:
|
| 369 |
-
return KU_OA_KNOWLEDGE
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KU Open Access Knowledge Base
|
| 3 |
+
Extracted from: https://library.ku.ac.ae/oa (all tabs)
|
| 4 |
+
Last extracted: May 2026
|
| 5 |
+
Loaded once at startup and injected into CHAT_SYSTEM_PROMPT.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
KU_OA_KNOWLEDGE = """
|
| 9 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 10 |
+
KU LIBRARY β OPEN ACCESS PUBLISHING KNOWLEDGE BASE
|
| 11 |
+
Source: https://library.ku.ac.ae/oa | Extracted May 2026
|
| 12 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 13 |
+
|
| 14 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 15 |
+
1. WHAT IS OPEN ACCESS?
|
| 16 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
Open Access (OA) publishing means published work can be accessed
|
| 18 |
+
by readers without a subscription or institutional access.
|
| 19 |
+
Because readers do not pay, publishers generate revenue by charging
|
| 20 |
+
the author an Article Processing Charge (APC).
|
| 21 |
+
|
| 22 |
+
KU supports three OA models:
|
| 23 |
+
|
| 24 |
+
GOLD OA:
|
| 25 |
+
- Journal is completely Open Access
|
| 26 |
+
- Publisher charges an APC to the author/institution
|
| 27 |
+
- All articles in the journal are freely available
|
| 28 |
+
|
| 29 |
+
HYBRID:
|
| 30 |
+
- Journal has a mix of OA and closed-access articles
|
| 31 |
+
- OA articles have an APC; traditional articles do not
|
| 32 |
+
- Author chooses at submission whether to go OA
|
| 33 |
+
|
| 34 |
+
GREEN OA:
|
| 35 |
+
- Author/institution publishes a copy in an institutional repository
|
| 36 |
+
- The repository copy is freely accessible even if the journal version is not
|
| 37 |
+
- KU's institutional repository is Khazna (khazna.ku.ac.ae)
|
| 38 |
+
|
| 39 |
+
Important: OA does not change the peer-review process. Articles must
|
| 40 |
+
still be accepted and pass peer review before publication.
|
| 41 |
+
|
| 42 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
2. KU OPEN ACCESS POLICY
|
| 44 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
KU administration is committed to encouraging and supporting faculty
|
| 46 |
+
to use Open Access publishing for their scholarly work.
|
| 47 |
+
|
| 48 |
+
Full policy document:
|
| 49 |
+
https://portal.ku.ac.ae/Policy/KU%20Poliy%20and%20Procedure%20Library/LIB%208540%20Open%20Access%20Publishing.pdf
|
| 50 |
+
|
| 51 |
+
OA contact email: openaccess@ku.ac.ae
|
| 52 |
+
OA guide: https://library.ku.ac.ae/oa/
|
| 53 |
+
|
| 54 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
3. KU OA FUNDING OPTIONS β THREE TIERS
|
| 56 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
+
|
| 58 |
+
TIER 1 β TOP 15% JOURNALS (Library pays APC only):
|
| 59 |
+
Conditions (ALL three must be met):
|
| 60 |
+
- At least one author's PRIMARY affiliation on the paper must be KU
|
| 61 |
+
- Paper must be in a journal from one of KU's 6 partner publishers:
|
| 62 |
+
ACS, Elsevier, IEEE, Springer Nature, Taylor & Francis, Wiley
|
| 63 |
+
- Journal must be rated in the TOP 15% by Scopus
|
| 64 |
+
|
| 65 |
+
Publisher-specific OA process pages:
|
| 66 |
+
- ACS: https://library.ku.ac.ae/oa/acs
|
| 67 |
+
- IEEE: https://library.ku.ac.ae/oa/ieee
|
| 68 |
+
- Elsevier: https://library.ku.ac.ae/oa/elsevier
|
| 69 |
+
- Springer Nature:https://library.ku.ac.ae/oa/SpringerNature
|
| 70 |
+
- Taylor & Francis:https://library.ku.ac.ae/oa/TandF
|
| 71 |
+
- Wiley: https://library.ku.ac.ae/oa/wiley
|
| 72 |
+
- Check ratings: https://library.ku.ac.ae/oa/ratings
|
| 73 |
+
|
| 74 |
+
What is covered: Full basic APC cost
|
| 75 |
+
What is NOT covered: Overlength page charges, colour page charges,
|
| 76 |
+
or any charges beyond the basic APC
|
| 77 |
+
|
| 78 |
+
How to apply: Process is handled automatically through the publisher
|
| 79 |
+
portal when the author selects KU as their institution.
|
| 80 |
+
|
| 81 |
+
Contact: openaccess@ku.ac.ae
|
| 82 |
+
|
| 83 |
+
TIER 2 β TOP 10% JOURNALS (APC + EPC via Provost office):
|
| 84 |
+
Conditions:
|
| 85 |
+
- Paper accepted in journal rated TOP 10% by Scopus
|
| 86 |
+
- KU must be author's primary affiliation
|
| 87 |
+
- Must have received email from library confirming they don't
|
| 88 |
+
cover these charges (i.e. not in Tier 1 agreement)
|
| 89 |
+
|
| 90 |
+
How to apply:
|
| 91 |
+
- Submit Payment Request (PMR) through KU Portal:
|
| 92 |
+
https://kuconnect.ku.ac.ae/group/guest/services#/service-details/831545
|
| 93 |
+
- Attach: Invoice + Final accepted manuscript + Library rejection email
|
| 94 |
+
|
| 95 |
+
Important notes:
|
| 96 |
+
- This is NOT a guarantee of approval β Provost office reviews each case
|
| 97 |
+
- Payments completed by finance team (their SLAs not in Provost control)
|
| 98 |
+
- Direct PMR only β expense reimbursement is NOT allowed
|
| 99 |
+
|
| 100 |
+
TIER 3 β Q1 JOURNALS (Department budget):
|
| 101 |
+
Conditions:
|
| 102 |
+
- Neither Tier 1 nor Tier 2 applies
|
| 103 |
+
- Paper published in a Q1 journal (Scopus-determined)
|
| 104 |
+
|
| 105 |
+
How to apply:
|
| 106 |
+
- Contact your department head to inquire about department budget support
|
| 107 |
+
- Direct PMR only β expense reimbursement is NOT allowed
|
| 108 |
+
|
| 109 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 110 |
+
4. HOW KU DETERMINES JOURNAL RATINGS (TOP 15%)
|
| 111 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 112 |
+
KU uses Scopus to determine journal ratings.
|
| 113 |
+
- Ratings page: https://library.ku.ac.ae/oa/ratings
|
| 114 |
+
|
| 115 |
+
Guidelines history:
|
| 116 |
+
- From 24 October 2023: journals must be in top 20% by Scopus
|
| 117 |
+
- From 1 January 2024 onwards: journals must be in top 15% by Scopus
|
| 118 |
+
(current requirement)
|
| 119 |
+
|
| 120 |
+
How to interpret Scopus scores:
|
| 121 |
+
https://service-elsevier-com.libconnect.ku.ac.ae/app/answers/detail/a_id/14834/supporthub/scopus/
|
| 122 |
+
|
| 123 |
+
βββββββββββββββββββββββοΏ½οΏ½οΏ½βββββββββββββββββββββββββ
|
| 124 |
+
5. PUBLISHER-SPECIFIC PROCESSES
|
| 125 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
+
|
| 127 |
+
ββ WILEY ββββββββββββββββββββββββββββββββββββββββββ
|
| 128 |
+
Agreement: Active. Covers Gold OA and Hybrid journals.
|
| 129 |
+
Covered journals list (top 15%):
|
| 130 |
+
Excel: https://library.ku.ac.ae/ld.php?content_id=84913015
|
| 131 |
+
PDF: https://library.ku.ac.ae/ld.php?content_id=84913016
|
| 132 |
+
|
| 133 |
+
Submission process:
|
| 134 |
+
1. Author submits article via Wiley Author Center
|
| 135 |
+
2. When asked who pays APC, select:
|
| 136 |
+
"My institution or funder has an agreement with Wiley and may pay"
|
| 137 |
+
3. Select Khalifa University using the institutional lookup tool
|
| 138 |
+
4. APC administrators notified β approved IF journal is top 15% (Scopus)
|
| 139 |
+
5. Paper goes through editing and peer review
|
| 140 |
+
6. If accepted, author acknowledges licence agreement
|
| 141 |
+
7. Paper published
|
| 142 |
+
|
| 143 |
+
Wiley Author Portal: http://www.wileyauthors.com/
|
| 144 |
+
OA resources: https://www.wiley.com/network/researchers/licensing-and-open-access
|
| 145 |
+
|
| 146 |
+
ββ ELSEVIER ββββββββββββββββββββββββββββββββββββββ
|
| 147 |
+
Agreement: Active. Covers Gold OA and Hybrid journals.
|
| 148 |
+
CRITICAL: Corresponding author MUST be affiliated with KU.
|
| 149 |
+
If corresponding author is from another university, that university
|
| 150 |
+
or the authors must pay the APC β KU cannot cover it.
|
| 151 |
+
|
| 152 |
+
Covered journals list (top 5%):
|
| 153 |
+
Excel: https://library.ku.ac.ae/ld.php?content_id=84910855
|
| 154 |
+
PDF: https://library.ku.ac.ae/ld.php?content_id=84910856
|
| 155 |
+
Note: Elsevier list covers top 5% (stricter than other publishers).
|
| 156 |
+
|
| 157 |
+
Submission process:
|
| 158 |
+
1. After acceptance, corresponding author receives email with link
|
| 159 |
+
2. Click "Rights and Access" form link
|
| 160 |
+
3. Corresponding author selects KU as affiliation
|
| 161 |
+
4. Add co-authors and their affiliations
|
| 162 |
+
5. Optionally add funder details
|
| 163 |
+
6. Choose Gold Open Access or Subscription
|
| 164 |
+
7. If OA chosen, select CC licence
|
| 165 |
+
8. Accept agreement and submit
|
| 166 |
+
9. KU administrators notified directly to pay the APC
|
| 167 |
+
|
| 168 |
+
ββ SPRINGER NATURE ββββββββββββββββββββββββββββββββ
|
| 169 |
+
Agreement: Active from agreement date. Covers HYBRID journals only.
|
| 170 |
+
IMPORTANT EXCLUSIONS:
|
| 171 |
+
- Agreement covers hybrid journals ONLY β NOT pure Gold OA journals
|
| 172 |
+
- Scientific Reports is specifically EXCLUDED from the agreement
|
| 173 |
+
(KU cannot pay APCs for Scientific Reports)
|
| 174 |
+
- Corresponding author MUST be affiliated with KU
|
| 175 |
+
|
| 176 |
+
Covered journals list (top 15%):
|
| 177 |
+
Excel: https://library.ku.ac.ae/ld.php?content_id=84909217
|
| 178 |
+
PDF: https://library.ku.ac.ae/ld.php?content_id=84909223
|
| 179 |
+
|
| 180 |
+
Submission process:
|
| 181 |
+
1. After editorial acceptance, corresponding author receives form
|
| 182 |
+
2. Confirm country
|
| 183 |
+
3. Confirm institutional affiliation (select KU)
|
| 184 |
+
4. Publisher checks eligibility β confirmation given if eligible
|
| 185 |
+
5. Author submits
|
| 186 |
+
6. KU staff notified
|
| 187 |
+
7. If journal meets KU guidelines β APC approved and paid
|
| 188 |
+
8. If journal does NOT meet guidelines β request rejected,
|
| 189 |
+
authors must arrange alternative payment
|
| 190 |
+
|
| 191 |
+
ββ TAYLOR & FRANCIS βββββββββββββββββββββββββββββββ
|
| 192 |
+
Agreement: Active from 1 January 2023.
|
| 193 |
+
Corresponding author MUST be affiliated with KU and use KU email
|
| 194 |
+
address β this allows publisher to automatically request APC from KU.
|
| 195 |
+
If corresponding author not affiliated with KU, KU cannot pay.
|
| 196 |
+
|
| 197 |
+
Note: Journal list currently being updated to reflect newest Scopus ratings.
|
| 198 |
+
|
| 199 |
+
Helpful T&F author resources:
|
| 200 |
+
- Why OA: https://authorservices.taylorandfrancis.com/choose-open/publishing-open-access/
|
| 201 |
+
- Writing advice: https://authorservices.taylorandfrancis.com/publishing-your-research/writing-your-paper/
|
| 202 |
+
- Submission info: https://authorservices.taylorandfrancis.com/publishing-your-research/making-your-submission/
|
| 203 |
+
|
| 204 |
+
ββ ACS (American Chemical Society) ββββββββββββββββ
|
| 205 |
+
Agreement: Active. 42 journals meet KU guidelines.
|
| 206 |
+
NOTE: ACS Omega is NOT rated in top 15% and is NOT eligible for KU OA payment.
|
| 207 |
+
|
| 208 |
+
Covered journals list (top 15%):
|
| 209 |
+
Excel: https://library.ku.ac.ae/ld.php?content_id=75321482
|
| 210 |
+
PDF: https://library.ku.ac.ae/ld.php?content_id=75321479
|
| 211 |
+
|
| 212 |
+
Submission process:
|
| 213 |
+
1. Submit article via ACS Publication Center: https://publish.acs.org/publish/
|
| 214 |
+
2. When asked who pays APC, select that the university may pay
|
| 215 |
+
3. Select Khalifa University using institutional lookup
|
| 216 |
+
4. APC administrators notified
|
| 217 |
+
- If journal is top 15% (Scopus) β APC approved
|
| 218 |
+
- If NOT top 15% β funding request rejected
|
| 219 |
+
5. Paper goes through editing and peer review
|
| 220 |
+
6. If accepted, author acknowledges licence agreement
|
| 221 |
+
7. Paper published
|
| 222 |
+
|
| 223 |
+
ACS tools: https://publish.acs.org/publish/
|
| 224 |
+
ACS OA info: https://acsopenaccess.org/
|
| 225 |
+
|
| 226 |
+
οΏ½οΏ½οΏ½β IEEE ββββββββββββββββββββββββββββββββββββββββββββ
|
| 227 |
+
Agreement: Active. KU has agreement with IEEE.
|
| 228 |
+
Contact openaccess@ku.ac.ae for current IEEE journal list and process details.
|
| 229 |
+
IEEE journal list: https://library.ku.ac.ae/oa/ieee
|
| 230 |
+
|
| 231 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 232 |
+
6. KHAZNA β KU INSTITUTIONAL REPOSITORY
|
| 233 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 234 |
+
URL: https://khazna.ku.ac.ae
|
| 235 |
+
Contact: khazna@ku.ac.ae
|
| 236 |
+
|
| 237 |
+
Khazna is KU's institutional repository (Green OA).
|
| 238 |
+
- All KU published articles should be deposited in Khazna
|
| 239 |
+
- Even under embargo, deposit the metadata record immediately
|
| 240 |
+
- Full text deposited once embargo expires
|
| 241 |
+
- Deposit increases visibility of KU research globally
|
| 242 |
+
- Supports KU OA compliance
|
| 243 |
+
|
| 244 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 245 |
+
7. RESEARCHBEE TOOLS β WHAT EACH DOES
|
| 246 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 247 |
+
ResearchBee has THREE tools available as tabs on the main page:
|
| 248 |
+
|
| 249 |
+
TOOL 1 β JOURNAL SUBMISSION TAB:
|
| 250 |
+
Two modes:
|
| 251 |
+
a) Browse by Subject: Type a discipline β get top 15 ranked journals
|
| 252 |
+
instantly from 53,000+ indexed titles. No manuscript needed.
|
| 253 |
+
b) Analyse Manuscript: Paste abstract β AI matches journals with full
|
| 254 |
+
Green OA policy breakdown, submission strategy, cover letter generation.
|
| 255 |
+
Use this for: finding where to publish, journal rankings, OA compliance.
|
| 256 |
+
|
| 257 |
+
TOOL 2 β LICENSE CHECKING TAB:
|
| 258 |
+
Look up self-archiving rights for a specific known journal.
|
| 259 |
+
Gives: version-specific permissions (preprint/AAM/VoR), embargo periods,
|
| 260 |
+
licence conditions, repository restrictions.
|
| 261 |
+
Use this for: "Can I deposit my accepted manuscript in Khazna?"
|
| 262 |
+
or "What's the embargo on this journal?"
|
| 263 |
+
|
| 264 |
+
TOOL 3 β DATA REPOSITORY TAB:
|
| 265 |
+
Describe a dataset β get matched repositories (domain-specific +
|
| 266 |
+
generalist like Zenodo, Dryad) + Khazna.
|
| 267 |
+
Gives: FAIR alignment, licence support, embargo support, cost, DOI provision.
|
| 268 |
+
Use this for: "Where should I deposit my research data?"
|
| 269 |
+
|
| 270 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 271 |
+
8. KEY CONTACTS & SUPPORT
|
| 272 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 273 |
+
OA Services: openaccess@ku.ac.ae
|
| 274 |
+
Khazna Repository: khazna@ku.ac.ae
|
| 275 |
+
Library general: library@ku.ac.ae
|
| 276 |
+
KU Library: https://library.ku.ac.ae/lib
|
| 277 |
+
OA Guide: https://library.ku.ac.ae/oa
|
| 278 |
+
|
| 279 |
+
RESEARCH SUPPORT β PRIMARY CONTACT:
|
| 280 |
+
Nikesh Narayanan, Research Librarian
|
| 281 |
+
Email: nikesh.narayanan@ku.ac.ae
|
| 282 |
+
For: journal selection advice, OA publishing support, research data management,
|
| 283 |
+
data management plans, repository selection, FAIR data guidance,
|
| 284 |
+
any publishing or research support question not answered by the tools.
|
| 285 |
+
|
| 286 |
+
PUBLISHER RECOMMENDATION FORM:
|
| 287 |
+
URL: https://kustar.libwizard.com/f/oapub
|
| 288 |
+
Purpose: Use this form to suggest a publisher or journal to be added to
|
| 289 |
+
KU Library's OA APC agreement. If a researcher wants KU to negotiate
|
| 290 |
+
an APC agreement with a publisher not currently covered, they submit
|
| 291 |
+
this form. The library reviews all suggestions.
|
| 292 |
+
|
| 293 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 294 |
+
8b. KU JOURNAL FINDER TOOLS GUIDE
|
| 295 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 296 |
+
KU Library maintains a comprehensive guide to journal finder tools:
|
| 297 |
+
URL: https://library.ku.ac.ae/journalfinder
|
| 298 |
+
Created by: Nikesh Narayanan, Research Librarian
|
| 299 |
+
|
| 300 |
+
The guide covers:
|
| 301 |
+
- ResearchBee (KU's own AI tool β recommended starting point)
|
| 302 |
+
- Cross-publisher AI matchers: EndNote Find a Journal, Trinka, JournalGuide, Paperpile, JANE, Editage
|
| 303 |
+
- Publisher-specific finders: Elsevier, Springer Nature, Wiley, Taylor & Francis, IEEE, SAGE, Emerald, MDPI
|
| 304 |
+
- Indexing & verification tools: Scopus Sources, WoS Master Journal List, DOAJ, SCImago, Think.Check.Submit
|
| 305 |
+
- Open access journal finders: Enago OA Journal Finder, DOAJ, PLOS
|
| 306 |
+
|
| 307 |
+
When to direct users there:
|
| 308 |
+
- "What tools can I use to find a journal?"
|
| 309 |
+
- "How do I choose the right journal for my paper?"
|
| 310 |
+
- "Are there other journal recommendation tools besides ResearchBee?"
|
| 311 |
+
- "Which Elsevier/Wiley/Springer journal should I target?"
|
| 312 |
+
- "How do I check if a journal is indexed?"
|
| 313 |
+
- "Is this journal trustworthy / predatory?"
|
| 314 |
+
|
| 315 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 316 |
+
9. RESEARCH ANALYTICS TOOLS (for citation & performance analysis)
|
| 317 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 318 |
+
For deep citation analysis, journal impact metrics, author h-index,
|
| 319 |
+
research performance, and institutional analytics β direct researchers
|
| 320 |
+
to these tools (all accessible via KU Library proxy):
|
| 321 |
+
|
| 322 |
+
SCOPUS (Elsevier):
|
| 323 |
+
Access: http://login.khalifa.idm.oclc.org/login?url=https://www.scopus.com
|
| 324 |
+
Best for: Journal rankings, citation counts, author profiles, h-index,
|
| 325 |
+
journal source metrics (CiteScore, SJR, SNIP)
|
| 326 |
+
|
| 327 |
+
WEB OF SCIENCE (Clarivate):
|
| 328 |
+
Access: http://login.khalifa.idm.oclc.org/login?url=https://www.webofscience.com
|
| 329 |
+
Best for: Citation reports, impact factor, Journal Citation Reports (JCR),
|
| 330 |
+
researcher profiles, highly cited papers
|
| 331 |
+
|
| 332 |
+
SCIVAL (Elsevier):
|
| 333 |
+
Access: http://login.khalifa.idm.oclc.org/login?url=https://www.scival.com
|
| 334 |
+
Best for: Institutional research performance, benchmarking KU against
|
| 335 |
+
other institutions, field-weighted citation impact (FWCI),
|
| 336 |
+
collaboration networks, research trends by topic
|
| 337 |
+
|
| 338 |
+
When to direct users there:
|
| 339 |
+
- "What is my h-index?" β Scopus or Web of Science
|
| 340 |
+
- "How does KU compare to other UAE universities?" β SciVal
|
| 341 |
+
- "What is the impact factor of this journal?" β Web of Science JCR
|
| 342 |
+
- "Show me citation trends for my field" β Scopus or SciVal
|
| 343 |
+
- "Who are the top authors in my field?" β Scopus
|
| 344 |
+
- "Detailed journal metrics beyond what ResearchBee shows" β Scopus Sources
|
| 345 |
+
|
| 346 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 347 |
+
10. APPROVED LINKS (chatbot may only use these)
|
| 348 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 349 |
+
- KU Journal Finder Guide: https://library.ku.ac.ae/journalfinder
|
| 350 |
+
- KU OA guide: https://library.ku.ac.ae/oa
|
| 351 |
+
- KU Library: https://library.ku.ac.ae/lib
|
| 352 |
+
- Khazna repository: https://khazna.ku.ac.ae
|
| 353 |
+
- OA policy document: https://portal.ku.ac.ae/Policy/KU%20Poliy%20and%20Procedure%20Library/LIB%208540%20Open%20Access%20Publishing.pdf
|
| 354 |
+
- Publisher recommendation: https://kustar.libwizard.com/f/oapub
|
| 355 |
+
- Open Policy Finder: https://openpolicyfinder.jisc.ac.uk
|
| 356 |
+
- re3data (repositories): https://www.re3data.org
|
| 357 |
+
- FAIRsharing: https://fairsharing.org
|
| 358 |
+
- Research Librarian: nikesh.narayanan@ku.ac.ae
|
| 359 |
+
- OA services email: openaccess@ku.ac.ae
|
| 360 |
+
- Khazna contact: khazna@ku.ac.ae
|
| 361 |
+
- Library email: library@ku.ac.ae
|
| 362 |
+
- Scopus (KU proxy): http://login.khalifa.idm.oclc.org/login?url=https://www.scopus.com
|
| 363 |
+
- Web of Science (KU proxy):http://login.khalifa.idm.oclc.org/login?url=https://www.webofscience.com
|
| 364 |
+
- SciVal (KU proxy): http://login.khalifa.idm.oclc.org/login?url=https://www.scival.com
|
| 365 |
+
""".strip()
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def get_knowledge() -> str:
|
| 369 |
+
return KU_OA_KNOWLEDGE
|
oaworks.py
CHANGED
|
@@ -153,46 +153,87 @@ def _licence_of(perm: dict) -> str:
|
|
| 153 |
return ", ".join(types)
|
| 154 |
|
| 155 |
|
| 156 |
-
def
|
| 157 |
-
"""
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
return None
|
| 165 |
|
| 166 |
|
| 167 |
-
def
|
| 168 |
"""
|
| 169 |
-
|
| 170 |
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
| 173 |
"""
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
if end:
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
|
| 184 |
def _permission_rank(perm: dict) -> tuple:
|
| 185 |
-
"""
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
embargo β the conservative choice, and the one OA.Works' own
|
| 193 |
-
best_permission makes.
|
| 194 |
-
"""
|
| 195 |
-
return (perm.get("score", 0), _embargo_months(perm) or 0)
|
| 196 |
|
| 197 |
|
| 198 |
def _covered_versions(perm: dict) -> list[str]:
|
|
@@ -220,7 +261,8 @@ def _blank() -> dict:
|
|
| 220 |
|
| 221 |
def _slot_from(perm: dict) -> dict:
|
| 222 |
locations = _clean_locations(perm)
|
| 223 |
-
embargo_txt,
|
|
|
|
| 224 |
conditions = []
|
| 225 |
if perm.get("deposit_statement"):
|
| 226 |
conditions.append("A specific deposit statement must accompany the file.")
|
|
@@ -228,7 +270,9 @@ def _slot_from(perm: dict) -> dict:
|
|
| 228 |
if issuer.get("notes"):
|
| 229 |
conditions.append(str(issuer["notes"]))
|
| 230 |
return {
|
| 231 |
-
"allowed": "Yes" if
|
|
|
|
|
|
|
| 232 |
"where": ", ".join(locations),
|
| 233 |
"embargo": embargo_txt,
|
| 234 |
"licence": _licence_of(perm),
|
|
@@ -286,8 +330,10 @@ def parse_permissions(payload: Optional[dict]) -> Optional[dict]:
|
|
| 286 |
elif isinstance(v, list):
|
| 287 |
evidence_urls += [str(u) for u in v if str(u).startswith("http")]
|
| 288 |
|
| 289 |
-
ir_ok = any(
|
| 290 |
-
|
|
|
|
|
|
|
| 291 |
|
| 292 |
updated = meta.get("updated") or meta.get("added") or ""
|
| 293 |
evidence = ("Source: ShareYourPaper Permissions (OA.Works) β the dataset behind "
|
|
@@ -296,8 +342,14 @@ def parse_permissions(payload: Optional[dict]) -> Optional[dict]:
|
|
| 296 |
if updated:
|
| 297 |
evidence += f" Policy record last updated {updated}."
|
| 298 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
return {
|
| 300 |
"policy_status": "Confirmed",
|
|
|
|
|
|
|
| 301 |
**slots,
|
| 302 |
"licence_notes": _licence_of(top),
|
| 303 |
"repository_action_note": "", # LLM fills this from these facts only
|
|
@@ -318,12 +370,14 @@ def parse_permissions(payload: Optional[dict]) -> Optional[dict]:
|
|
| 318 |
"monitoring": meta.get("monitoring", ""),
|
| 319 |
|
| 320 |
# ---- internal, for deterministic downstream logic ----
|
| 321 |
-
"_can_archive":
|
| 322 |
"_versions": sorted(chosen.keys()),
|
| 323 |
"_best_version": VERSION_MAP.get(str(top.get("version", "")).lower(), ""),
|
| 324 |
"_locations": _clean_locations(top),
|
| 325 |
"_institutional_ok": ir_ok,
|
| 326 |
-
"_embargo_months":
|
|
|
|
|
|
|
| 327 |
"_licence": _licence_of(top),
|
| 328 |
}
|
| 329 |
|
|
@@ -334,6 +388,8 @@ def not_confirmed(journal: str = "", issn: str = "", reason: str = "") -> dict:
|
|
| 334 |
verify += f"search?term={quote(issn)}"
|
| 335 |
return {
|
| 336 |
"policy_status": "Not confirmed",
|
|
|
|
|
|
|
| 337 |
"preprint": _blank(), "postprint": _blank(),
|
| 338 |
"published_version": _blank(),
|
| 339 |
"licence_notes": "",
|
|
@@ -343,33 +399,91 @@ def not_confirmed(journal: str = "", issn: str = "", reason: str = "") -> dict:
|
|
| 343 |
f"{(' β ' + reason) if reason else ''}. Verify at {verify}"),
|
| 344 |
"risk_flag": "Policy not verified β confirm before depositing.",
|
| 345 |
"deposit_statement": "",
|
|
|
|
| 346 |
"evidence_urls": [],
|
| 347 |
-
"_can_archive":
|
| 348 |
"_versions": [],
|
|
|
|
|
|
|
| 349 |
"_institutional_ok": False,
|
| 350 |
"_embargo_months": None,
|
|
|
|
|
|
|
|
|
|
| 351 |
"_verify_url": verify,
|
| 352 |
}
|
| 353 |
|
| 354 |
|
| 355 |
# ββ Deterministic recommendation β no LLM ββββββββββββββββββββββββββββββββββ
|
| 356 |
def recommendation_from(green: dict) -> dict:
|
| 357 |
-
|
| 358 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
best = green.get("_best_version", "")
|
| 360 |
-
checks = []
|
| 361 |
-
|
|
|
|
| 362 |
checks.append("Verify the policy at Open Policy Finder before depositing.")
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
return {
|
| 367 |
-
"
|
| 368 |
-
"
|
| 369 |
-
|
| 370 |
-
"
|
| 371 |
-
"
|
| 372 |
-
"
|
|
|
|
| 373 |
"manual_checks_required": checks,
|
| 374 |
}
|
| 375 |
|
|
@@ -377,22 +491,36 @@ def recommendation_from(green: dict) -> dict:
|
|
| 377 |
def next_actions_from(green: dict, doi: str,
|
| 378 |
khazna: dict | None = None) -> list[str]:
|
| 379 |
acts: list[str] = []
|
| 380 |
-
|
|
|
|
|
|
|
|
|
|
| 381 |
if green.get("_institutional_ok"):
|
| 382 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
if green.get("deposit_statement"):
|
| 384 |
acts.append("Include the required deposit statement with the file.")
|
| 385 |
link = share_link(doi)
|
| 386 |
if link:
|
| 387 |
acts.append(f"Or deposit via ShareYourPaper: {link}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
else:
|
| 389 |
-
acts.append(
|
| 390 |
-
|
|
|
|
|
|
|
| 391 |
if (khazna and khazna.get("checked") and khazna.get("in_khazna")
|
| 392 |
and khazna.get("needs_deposit")):
|
| 393 |
acts.insert(0, "This work is already recorded in the KU repository, Khazna, "
|
| 394 |
-
"but has no open full text attached β
|
| 395 |
-
"
|
| 396 |
return acts
|
| 397 |
|
| 398 |
|
|
|
|
| 153 |
return ", ".join(types)
|
| 154 |
|
| 155 |
|
| 156 |
+
def _normalise_embargo_months(value) -> Optional[int]:
|
| 157 |
+
"""Return a non-negative month value only when the source states one."""
|
| 158 |
+
if isinstance(value, bool):
|
| 159 |
+
return None
|
| 160 |
+
if isinstance(value, (int, float)) and value >= 0:
|
| 161 |
+
return int(value)
|
| 162 |
+
if isinstance(value, str):
|
| 163 |
+
raw = value.strip().lower()
|
| 164 |
+
if raw in {"none", "no embargo", "immediate", "0", "0.0"}:
|
| 165 |
+
return 0
|
| 166 |
+
match = _re.fullmatch(r"(\d+)\s*(?:months?|mos?)?", raw)
|
| 167 |
+
if match:
|
| 168 |
+
return int(match.group(1))
|
| 169 |
return None
|
| 170 |
|
| 171 |
|
| 172 |
+
def _embargo_details(perm: dict) -> tuple[str, Optional[int], str]:
|
| 173 |
"""
|
| 174 |
+
Return ``(display_text, months, status)`` without collapsing unknown to zero.
|
| 175 |
|
| 176 |
+
``status`` is one of:
|
| 177 |
+
* ``zero`` β the permission record explicitly states zero/no embargo;
|
| 178 |
+
* ``stated`` β a positive duration or article-specific end date is stated;
|
| 179 |
+
* ``unknown`` β the record contains no usable embargo statement.
|
| 180 |
"""
|
| 181 |
+
for key in ("embargo_months", "embargoMonths"):
|
| 182 |
+
if key not in perm:
|
| 183 |
+
continue
|
| 184 |
+
months = _normalise_embargo_months(perm.get(key))
|
| 185 |
+
if months is None:
|
| 186 |
+
continue
|
| 187 |
+
if months == 0:
|
| 188 |
+
return "No embargo (0 months stated in the permission record)", 0, "zero"
|
| 189 |
+
end = perm.get("embargo_end")
|
| 190 |
+
text = f"{months} months"
|
| 191 |
+
if end:
|
| 192 |
+
text += f" (ends {end} for this article)"
|
| 193 |
+
return text, months, "stated"
|
| 194 |
+
|
| 195 |
+
# Some responses may use a generic embargo value rather than the confirmed
|
| 196 |
+
# month keys. Accept only explicit, machine-readable values.
|
| 197 |
+
if "embargo" in perm and not isinstance(perm.get("embargo"), dict):
|
| 198 |
+
months = _normalise_embargo_months(perm.get("embargo"))
|
| 199 |
+
if months is not None:
|
| 200 |
+
if months == 0:
|
| 201 |
+
return "No embargo (0 months stated in the permission record)", 0, "zero"
|
| 202 |
+
end = perm.get("embargo_end")
|
| 203 |
+
text = f"{months} months"
|
| 204 |
+
if end:
|
| 205 |
+
text += f" (ends {end} for this article)"
|
| 206 |
+
return text, months, "stated"
|
| 207 |
+
|
| 208 |
+
end = str(perm.get("embargo_end") or "").strip()
|
| 209 |
if end:
|
| 210 |
+
return f"Embargo ends {end} for this article", None, "stated"
|
| 211 |
+
|
| 212 |
+
return "Not stated in the permission record", None, "unknown"
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _embargo_months(perm: dict) -> Optional[int]:
|
| 216 |
+
"""Compatibility helper: 0, positive months, or None when not stated."""
|
| 217 |
+
return _embargo_details(perm)[1]
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _embargo_of(perm: dict) -> tuple[str, Optional[int]]:
|
| 221 |
+
text, months, _status = _embargo_details(perm)
|
| 222 |
+
return text, months
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _embargo_status(perm: dict) -> str:
|
| 226 |
+
return _embargo_details(perm)[2]
|
| 227 |
|
| 228 |
|
| 229 |
def _permission_rank(perm: dict) -> tuple:
|
| 230 |
+
"""Conservative sort key for competing permissions of equal source score."""
|
| 231 |
+
_text, months, status = _embargo_details(perm)
|
| 232 |
+
# Prefer a known positive/end-dated embargo, then an unknown embargo, then
|
| 233 |
+
# an explicit zero embargo. This avoids silently selecting "immediate" when
|
| 234 |
+
# another equally authoritative record leaves timing unknown.
|
| 235 |
+
status_rank = {"stated": 2, "unknown": 1, "zero": 0}.get(status, 1)
|
| 236 |
+
return (perm.get("score", 0), status_rank, months or 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
|
| 239 |
def _covered_versions(perm: dict) -> list[str]:
|
|
|
|
| 261 |
|
| 262 |
def _slot_from(perm: dict) -> dict:
|
| 263 |
locations = _clean_locations(perm)
|
| 264 |
+
embargo_txt, _months, embargo_status = _embargo_details(perm)
|
| 265 |
+
can_archive = bool(perm.get("can_archive"))
|
| 266 |
conditions = []
|
| 267 |
if perm.get("deposit_statement"):
|
| 268 |
conditions.append("A specific deposit statement must accompany the file.")
|
|
|
|
| 270 |
if issuer.get("notes"):
|
| 271 |
conditions.append(str(issuer["notes"]))
|
| 272 |
return {
|
| 273 |
+
"allowed": "Yes" if can_archive else "No",
|
| 274 |
+
"permission_status": "allowed" if can_archive else "denied",
|
| 275 |
+
"embargo_status": embargo_status,
|
| 276 |
"where": ", ".join(locations),
|
| 277 |
"embargo": embargo_txt,
|
| 278 |
"licence": _licence_of(perm),
|
|
|
|
| 330 |
elif isinstance(v, list):
|
| 331 |
evidence_urls += [str(u) for u in v if str(u).startswith("http")]
|
| 332 |
|
| 333 |
+
ir_ok = any(
|
| 334 |
+
p.get("can_archive") is True and INSTITUTIONAL in location.lower()
|
| 335 |
+
for p in chosen.values() for location in _clean_locations(p)
|
| 336 |
+
)
|
| 337 |
|
| 338 |
updated = meta.get("updated") or meta.get("added") or ""
|
| 339 |
evidence = ("Source: ShareYourPaper Permissions (OA.Works) β the dataset behind "
|
|
|
|
| 342 |
if updated:
|
| 343 |
evidence += f" Policy record last updated {updated}."
|
| 344 |
|
| 345 |
+
can_archive = bool(top.get("can_archive"))
|
| 346 |
+
embargo_text, embargo_months, embargo_status = _embargo_details(top)
|
| 347 |
+
permission_status = "allowed" if can_archive else "denied"
|
| 348 |
+
|
| 349 |
return {
|
| 350 |
"policy_status": "Confirmed",
|
| 351 |
+
"permission_status": permission_status,
|
| 352 |
+
"embargo_status": embargo_status,
|
| 353 |
**slots,
|
| 354 |
"licence_notes": _licence_of(top),
|
| 355 |
"repository_action_note": "", # LLM fills this from these facts only
|
|
|
|
| 370 |
"monitoring": meta.get("monitoring", ""),
|
| 371 |
|
| 372 |
# ---- internal, for deterministic downstream logic ----
|
| 373 |
+
"_can_archive": can_archive,
|
| 374 |
"_versions": sorted(chosen.keys()),
|
| 375 |
"_best_version": VERSION_MAP.get(str(top.get("version", "")).lower(), ""),
|
| 376 |
"_locations": _clean_locations(top),
|
| 377 |
"_institutional_ok": ir_ok,
|
| 378 |
+
"_embargo_months": embargo_months,
|
| 379 |
+
"_embargo_status": embargo_status,
|
| 380 |
+
"_embargo_text": embargo_text,
|
| 381 |
"_licence": _licence_of(top),
|
| 382 |
}
|
| 383 |
|
|
|
|
| 388 |
verify += f"search?term={quote(issn)}"
|
| 389 |
return {
|
| 390 |
"policy_status": "Not confirmed",
|
| 391 |
+
"permission_status": "unknown",
|
| 392 |
+
"embargo_status": "unknown",
|
| 393 |
"preprint": _blank(), "postprint": _blank(),
|
| 394 |
"published_version": _blank(),
|
| 395 |
"licence_notes": "",
|
|
|
|
| 399 |
f"{(' β ' + reason) if reason else ''}. Verify at {verify}"),
|
| 400 |
"risk_flag": "Policy not verified β confirm before depositing.",
|
| 401 |
"deposit_statement": "",
|
| 402 |
+
"embargo_end": "",
|
| 403 |
"evidence_urls": [],
|
| 404 |
+
"_can_archive": None,
|
| 405 |
"_versions": [],
|
| 406 |
+
"_best_version": "",
|
| 407 |
+
"_locations": [],
|
| 408 |
"_institutional_ok": False,
|
| 409 |
"_embargo_months": None,
|
| 410 |
+
"_embargo_status": "unknown",
|
| 411 |
+
"_embargo_text": "Not stated in a verified permission record",
|
| 412 |
+
"_licence": "",
|
| 413 |
"_verify_url": verify,
|
| 414 |
}
|
| 415 |
|
| 416 |
|
| 417 |
# ββ Deterministic recommendation β no LLM ββββββββββββββββββββββββββββββββββ
|
| 418 |
def recommendation_from(green: dict) -> dict:
|
| 419 |
+
permission = str(green.get("permission_status") or "").lower()
|
| 420 |
+
if permission not in {"allowed", "denied", "unknown"}:
|
| 421 |
+
raw = green.get("_can_archive")
|
| 422 |
+
permission = "allowed" if raw is True else "denied" if raw is False else "unknown"
|
| 423 |
+
|
| 424 |
+
embargo_status = str(
|
| 425 |
+
green.get("embargo_status") or green.get("_embargo_status") or "unknown"
|
| 426 |
+
).lower()
|
| 427 |
+
if embargo_status not in {"zero", "stated", "unknown"}:
|
| 428 |
+
embargo_status = "unknown"
|
| 429 |
+
|
| 430 |
+
months = green.get("_embargo_months")
|
| 431 |
best = green.get("_best_version", "")
|
| 432 |
+
checks: list[str] = []
|
| 433 |
+
|
| 434 |
+
if permission == "unknown":
|
| 435 |
checks.append("Verify the policy at Open Policy Finder before depositing.")
|
| 436 |
+
return {
|
| 437 |
+
"permission_status": "unknown",
|
| 438 |
+
"embargo_status": "unknown",
|
| 439 |
+
"best_version_to_deposit": "Not determined",
|
| 440 |
+
"best_timing": "Not determined β verify",
|
| 441 |
+
"immediate_open_deposit_possible": "Unknown",
|
| 442 |
+
"embargoed_deposit_needed": "Unknown",
|
| 443 |
+
"metadata_only_first": "Unknown",
|
| 444 |
+
"manual_checks_required": checks,
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
if permission == "denied":
|
| 448 |
+
return {
|
| 449 |
+
"permission_status": "denied",
|
| 450 |
+
"embargo_status": embargo_status,
|
| 451 |
+
"best_version_to_deposit": "No permitted self-archived version identified",
|
| 452 |
+
"best_timing": "Not applicable",
|
| 453 |
+
"immediate_open_deposit_possible": "No",
|
| 454 |
+
"embargoed_deposit_needed": "No",
|
| 455 |
+
"metadata_only_first": "No",
|
| 456 |
+
"manual_checks_required": checks,
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
# Permission is allowed from this point onward.
|
| 460 |
+
if embargo_status == "zero":
|
| 461 |
+
immediate, embargoed, metadata = "Yes", "No", "No"
|
| 462 |
+
timing = "Immediate open deposit is permitted by the verified record"
|
| 463 |
+
elif embargo_status == "stated":
|
| 464 |
+
immediate, embargoed, metadata = "No", "Yes", "Yes"
|
| 465 |
+
if isinstance(months, int) and months > 0:
|
| 466 |
+
timing = f"After a {months}-month embargo"
|
| 467 |
+
elif green.get("embargo_end"):
|
| 468 |
+
timing = f"After the stated embargo ends ({green['embargo_end']})"
|
| 469 |
+
else:
|
| 470 |
+
timing = "After the stated embargo; verify the release date"
|
| 471 |
+
checks.append("Confirm the exact embargo release date before opening the file.")
|
| 472 |
+
else:
|
| 473 |
+
immediate, embargoed, metadata = "Unknown", "Unknown", "Unknown"
|
| 474 |
+
timing = "Embargo not stated β verify before opening the file"
|
| 475 |
+
checks.append(
|
| 476 |
+
"The verified permission record does not state an embargo; confirm timing before immediate open deposit."
|
| 477 |
+
)
|
| 478 |
+
|
| 479 |
return {
|
| 480 |
+
"permission_status": "allowed",
|
| 481 |
+
"embargo_status": embargo_status,
|
| 482 |
+
"best_version_to_deposit": best or "Permitted version not specified",
|
| 483 |
+
"best_timing": timing,
|
| 484 |
+
"immediate_open_deposit_possible": immediate,
|
| 485 |
+
"embargoed_deposit_needed": embargoed,
|
| 486 |
+
"metadata_only_first": metadata,
|
| 487 |
"manual_checks_required": checks,
|
| 488 |
}
|
| 489 |
|
|
|
|
| 491 |
def next_actions_from(green: dict, doi: str,
|
| 492 |
khazna: dict | None = None) -> list[str]:
|
| 493 |
acts: list[str] = []
|
| 494 |
+
permission = green.get("_can_archive")
|
| 495 |
+
embargo_status = green.get("_embargo_status") or green.get("embargo_status")
|
| 496 |
+
|
| 497 |
+
if permission is True:
|
| 498 |
if green.get("_institutional_ok"):
|
| 499 |
+
if embargo_status == "unknown":
|
| 500 |
+
acts.append(
|
| 501 |
+
"Prepare the permitted version for Khazna, but verify the release timing before making the file open."
|
| 502 |
+
)
|
| 503 |
+
else:
|
| 504 |
+
acts.append("Deposit the permitted version in the KU repository, Khazna, using the verified timing.")
|
| 505 |
if green.get("deposit_statement"):
|
| 506 |
acts.append("Include the required deposit statement with the file.")
|
| 507 |
link = share_link(doi)
|
| 508 |
if link:
|
| 509 |
acts.append(f"Or deposit via ShareYourPaper: {link}")
|
| 510 |
+
elif permission is False:
|
| 511 |
+
acts.append(
|
| 512 |
+
"The verified permission record does not permit self-archiving for the selected version; check whether a KU APC agreement supports Gold OA."
|
| 513 |
+
)
|
| 514 |
else:
|
| 515 |
+
acts.append(
|
| 516 |
+
"ResearchBee could not determine whether self-archiving is permitted. Verify the journal policy before depositing any version."
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
if (khazna and khazna.get("checked") and khazna.get("in_khazna")
|
| 520 |
and khazna.get("needs_deposit")):
|
| 521 |
acts.insert(0, "This work is already recorded in the KU repository, Khazna, "
|
| 522 |
+
"but has no open full text attached β add a file only after "
|
| 523 |
+
"the permitted version and timing have been verified.")
|
| 524 |
return acts
|
| 525 |
|
| 526 |
|
openalex.py
CHANGED
|
@@ -1,145 +1,145 @@
|
|
| 1 |
-
"""
|
| 2 |
-
OpenAlex enrichment β journal metadata + peer percentile calculation.
|
| 3 |
-
Free API, no key required. Uses polite pool with mailto header.
|
| 4 |
-
|
| 5 |
-
Changes from v1.0:
|
| 6 |
-
- Shared module-level httpx.AsyncClient (init_client / close_client for lifespan)
|
| 7 |
-
- compute_percentile accepts an optional concept_cache dict to deduplicate
|
| 8 |
-
identical concept peer-set fetches across journals in one request
|
| 9 |
-
- _norm_issn removed β uses utils.norm_issn
|
| 10 |
-
"""
|
| 11 |
-
import httpx
|
| 12 |
-
import asyncio
|
| 13 |
-
from typing import Optional
|
| 14 |
-
from utils import norm_issn
|
| 15 |
-
|
| 16 |
-
UA = "ResearchNavigator/1.0 (mailto:library@ku.ac.ae)"
|
| 17 |
-
BASE = "https://api.openalex.org"
|
| 18 |
-
|
| 19 |
-
_http_client: Optional[httpx.AsyncClient] = None
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def init_client() -> None:
|
| 23 |
-
global _http_client
|
| 24 |
-
_http_client = httpx.AsyncClient(
|
| 25 |
-
timeout=httpx.Timeout(10.0, connect=5.0),
|
| 26 |
-
headers={"User-Agent": UA},
|
| 27 |
-
)
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
async def close_client() -> None:
|
| 31 |
-
global _http_client
|
| 32 |
-
if _http_client and not _http_client.is_closed:
|
| 33 |
-
await _http_client.aclose()
|
| 34 |
-
_http_client = None
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def _get_client() -> httpx.AsyncClient:
|
| 38 |
-
if _http_client is None or _http_client.is_closed:
|
| 39 |
-
init_client()
|
| 40 |
-
return _http_client
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
async def enrich_journal(name: str, issn: Optional[str]) -> Optional[dict]:
|
| 44 |
-
client = _get_client()
|
| 45 |
-
try:
|
| 46 |
-
if issn:
|
| 47 |
-
clean = norm_issn(issn) or issn.strip()
|
| 48 |
-
r = await client.get(f"{BASE}/sources/issn:{clean}")
|
| 49 |
-
else:
|
| 50 |
-
r = await client.get(
|
| 51 |
-
f"{BASE}/sources", params={"search": name, "per_page": 1}
|
| 52 |
-
)
|
| 53 |
-
if not r.is_success:
|
| 54 |
-
return None
|
| 55 |
-
d = r.json()
|
| 56 |
-
s = d if issn else (d.get("results") or [None])[0]
|
| 57 |
-
if not s:
|
| 58 |
-
return None
|
| 59 |
-
return {
|
| 60 |
-
"openalex_id": s.get("id"),
|
| 61 |
-
"display_name": s.get("display_name"),
|
| 62 |
-
"issn_l": s.get("issn_l"),
|
| 63 |
-
"issns": s.get("issn", []),
|
| 64 |
-
"host_organization": s.get("host_organization_name"),
|
| 65 |
-
"is_oa": s.get("is_oa"),
|
| 66 |
-
"is_in_doaj": s.get("is_in_doaj"),
|
| 67 |
-
"works_count": s.get("works_count"),
|
| 68 |
-
"cited_by_count": s.get("cited_by_count"),
|
| 69 |
-
"h_index": (s.get("summary_stats") or {}).get("h_index"),
|
| 70 |
-
"two_yr_mean_citedness": (s.get("summary_stats") or {}).get("2yr_mean_citedness"),
|
| 71 |
-
"homepage_url": s.get("homepage_url"),
|
| 72 |
-
"top_concept": ((s.get("x_concepts") or [{}])[0]).get("display_name"),
|
| 73 |
-
"top_concept_id": ((s.get("x_concepts") or [{}])[0]).get("id"),
|
| 74 |
-
}
|
| 75 |
-
except Exception as e:
|
| 76 |
-
print(f"[OpenAlex] enrich error for {name}: {e}")
|
| 77 |
-
return None
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
async def compute_percentile(
|
| 81 |
-
openalex: dict,
|
| 82 |
-
concept_cache: Optional[dict] = None,
|
| 83 |
-
) -> Optional[dict]:
|
| 84 |
-
try:
|
| 85 |
-
if not openalex or not openalex.get("top_concept_id"):
|
| 86 |
-
return None
|
| 87 |
-
val = openalex.get("two_yr_mean_citedness")
|
| 88 |
-
if val is None:
|
| 89 |
-
return None
|
| 90 |
-
|
| 91 |
-
concept_id = str(openalex["top_concept_id"]).split("/")[-1]
|
| 92 |
-
|
| 93 |
-
if concept_cache is not None and concept_id in concept_cache:
|
| 94 |
-
peers = concept_cache[concept_id]
|
| 95 |
-
else:
|
| 96 |
-
client = _get_client()
|
| 97 |
-
r = await client.get(
|
| 98 |
-
f"{BASE}/sources",
|
| 99 |
-
params={
|
| 100 |
-
"filter": f"concepts.id:{concept_id},type:journal",
|
| 101 |
-
"per_page": 200,
|
| 102 |
-
"sort": "summary_stats.2yr_mean_citedness:desc",
|
| 103 |
-
},
|
| 104 |
-
)
|
| 105 |
-
if not r.is_success:
|
| 106 |
-
return None
|
| 107 |
-
peers = [
|
| 108 |
-
s.get("summary_stats", {}).get("2yr_mean_citedness")
|
| 109 |
-
for s in r.json().get("results", [])
|
| 110 |
-
]
|
| 111 |
-
peers = [v for v in peers if isinstance(v, (int, float))]
|
| 112 |
-
if concept_cache is not None:
|
| 113 |
-
concept_cache[concept_id] = peers
|
| 114 |
-
|
| 115 |
-
if len(peers) < 10:
|
| 116 |
-
return None
|
| 117 |
-
|
| 118 |
-
below = sum(1 for v in peers if v < val)
|
| 119 |
-
pct = round(below / len(peers) * 100)
|
| 120 |
-
q = "Q1" if pct >= 75 else "Q2" if pct >= 50 else "Q3" if pct >= 25 else "Q4"
|
| 121 |
-
return {
|
| 122 |
-
"percentile": pct,
|
| 123 |
-
"quartile": q,
|
| 124 |
-
"peer_count": len(peers),
|
| 125 |
-
"concept": openalex.get("top_concept"),
|
| 126 |
-
}
|
| 127 |
-
except Exception as e:
|
| 128 |
-
print(f"[OpenAlex] percentile error: {e}")
|
| 129 |
-
return None
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
async def enrich_all(journals: list) -> list:
|
| 133 |
-
"""Enrich a list of journal dicts in parallel, deduplicating concept peer-sets."""
|
| 134 |
-
concept_cache: dict = {}
|
| 135 |
-
|
| 136 |
-
async def _enrich_one(j: dict) -> dict:
|
| 137 |
-
oa = await enrich_journal(j.get("name", ""), j.get("issn"))
|
| 138 |
-
pct = await compute_percentile(oa, concept_cache) if oa else None
|
| 139 |
-
j["openalex"] = oa
|
| 140 |
-
j["oa_percentile"] = pct
|
| 141 |
-
if not j.get("issn") and oa:
|
| 142 |
-
j["issn"] = oa.get("issn_l") or (oa.get("issns") or [None])[0]
|
| 143 |
-
return j
|
| 144 |
-
|
| 145 |
-
return list(await asyncio.gather(*[_enrich_one(j) for j in journals]))
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OpenAlex enrichment β journal metadata + peer percentile calculation.
|
| 3 |
+
Free API, no key required. Uses polite pool with mailto header.
|
| 4 |
+
|
| 5 |
+
Changes from v1.0:
|
| 6 |
+
- Shared module-level httpx.AsyncClient (init_client / close_client for lifespan)
|
| 7 |
+
- compute_percentile accepts an optional concept_cache dict to deduplicate
|
| 8 |
+
identical concept peer-set fetches across journals in one request
|
| 9 |
+
- _norm_issn removed β uses utils.norm_issn
|
| 10 |
+
"""
|
| 11 |
+
import httpx
|
| 12 |
+
import asyncio
|
| 13 |
+
from typing import Optional
|
| 14 |
+
from utils import norm_issn
|
| 15 |
+
|
| 16 |
+
UA = "ResearchNavigator/1.0 (mailto:library@ku.ac.ae)"
|
| 17 |
+
BASE = "https://api.openalex.org"
|
| 18 |
+
|
| 19 |
+
_http_client: Optional[httpx.AsyncClient] = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def init_client() -> None:
|
| 23 |
+
global _http_client
|
| 24 |
+
_http_client = httpx.AsyncClient(
|
| 25 |
+
timeout=httpx.Timeout(10.0, connect=5.0),
|
| 26 |
+
headers={"User-Agent": UA},
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def close_client() -> None:
|
| 31 |
+
global _http_client
|
| 32 |
+
if _http_client and not _http_client.is_closed:
|
| 33 |
+
await _http_client.aclose()
|
| 34 |
+
_http_client = None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _get_client() -> httpx.AsyncClient:
|
| 38 |
+
if _http_client is None or _http_client.is_closed:
|
| 39 |
+
init_client()
|
| 40 |
+
return _http_client
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def enrich_journal(name: str, issn: Optional[str]) -> Optional[dict]:
|
| 44 |
+
client = _get_client()
|
| 45 |
+
try:
|
| 46 |
+
if issn:
|
| 47 |
+
clean = norm_issn(issn) or issn.strip()
|
| 48 |
+
r = await client.get(f"{BASE}/sources/issn:{clean}")
|
| 49 |
+
else:
|
| 50 |
+
r = await client.get(
|
| 51 |
+
f"{BASE}/sources", params={"search": name, "per_page": 1}
|
| 52 |
+
)
|
| 53 |
+
if not r.is_success:
|
| 54 |
+
return None
|
| 55 |
+
d = r.json()
|
| 56 |
+
s = d if issn else (d.get("results") or [None])[0]
|
| 57 |
+
if not s:
|
| 58 |
+
return None
|
| 59 |
+
return {
|
| 60 |
+
"openalex_id": s.get("id"),
|
| 61 |
+
"display_name": s.get("display_name"),
|
| 62 |
+
"issn_l": s.get("issn_l"),
|
| 63 |
+
"issns": s.get("issn", []),
|
| 64 |
+
"host_organization": s.get("host_organization_name"),
|
| 65 |
+
"is_oa": s.get("is_oa"),
|
| 66 |
+
"is_in_doaj": s.get("is_in_doaj"),
|
| 67 |
+
"works_count": s.get("works_count"),
|
| 68 |
+
"cited_by_count": s.get("cited_by_count"),
|
| 69 |
+
"h_index": (s.get("summary_stats") or {}).get("h_index"),
|
| 70 |
+
"two_yr_mean_citedness": (s.get("summary_stats") or {}).get("2yr_mean_citedness"),
|
| 71 |
+
"homepage_url": s.get("homepage_url"),
|
| 72 |
+
"top_concept": ((s.get("x_concepts") or [{}])[0]).get("display_name"),
|
| 73 |
+
"top_concept_id": ((s.get("x_concepts") or [{}])[0]).get("id"),
|
| 74 |
+
}
|
| 75 |
+
except Exception as e:
|
| 76 |
+
print(f"[OpenAlex] enrich error for {name}: {e}")
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def compute_percentile(
|
| 81 |
+
openalex: dict,
|
| 82 |
+
concept_cache: Optional[dict] = None,
|
| 83 |
+
) -> Optional[dict]:
|
| 84 |
+
try:
|
| 85 |
+
if not openalex or not openalex.get("top_concept_id"):
|
| 86 |
+
return None
|
| 87 |
+
val = openalex.get("two_yr_mean_citedness")
|
| 88 |
+
if val is None:
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
concept_id = str(openalex["top_concept_id"]).split("/")[-1]
|
| 92 |
+
|
| 93 |
+
if concept_cache is not None and concept_id in concept_cache:
|
| 94 |
+
peers = concept_cache[concept_id]
|
| 95 |
+
else:
|
| 96 |
+
client = _get_client()
|
| 97 |
+
r = await client.get(
|
| 98 |
+
f"{BASE}/sources",
|
| 99 |
+
params={
|
| 100 |
+
"filter": f"concepts.id:{concept_id},type:journal",
|
| 101 |
+
"per_page": 200,
|
| 102 |
+
"sort": "summary_stats.2yr_mean_citedness:desc",
|
| 103 |
+
},
|
| 104 |
+
)
|
| 105 |
+
if not r.is_success:
|
| 106 |
+
return None
|
| 107 |
+
peers = [
|
| 108 |
+
s.get("summary_stats", {}).get("2yr_mean_citedness")
|
| 109 |
+
for s in r.json().get("results", [])
|
| 110 |
+
]
|
| 111 |
+
peers = [v for v in peers if isinstance(v, (int, float))]
|
| 112 |
+
if concept_cache is not None:
|
| 113 |
+
concept_cache[concept_id] = peers
|
| 114 |
+
|
| 115 |
+
if len(peers) < 10:
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
below = sum(1 for v in peers if v < val)
|
| 119 |
+
pct = round(below / len(peers) * 100)
|
| 120 |
+
q = "Q1" if pct >= 75 else "Q2" if pct >= 50 else "Q3" if pct >= 25 else "Q4"
|
| 121 |
+
return {
|
| 122 |
+
"percentile": pct,
|
| 123 |
+
"quartile": q,
|
| 124 |
+
"peer_count": len(peers),
|
| 125 |
+
"concept": openalex.get("top_concept"),
|
| 126 |
+
}
|
| 127 |
+
except Exception as e:
|
| 128 |
+
print(f"[OpenAlex] percentile error: {e}")
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
async def enrich_all(journals: list) -> list:
|
| 133 |
+
"""Enrich a list of journal dicts in parallel, deduplicating concept peer-sets."""
|
| 134 |
+
concept_cache: dict = {}
|
| 135 |
+
|
| 136 |
+
async def _enrich_one(j: dict) -> dict:
|
| 137 |
+
oa = await enrich_journal(j.get("name", ""), j.get("issn"))
|
| 138 |
+
pct = await compute_percentile(oa, concept_cache) if oa else None
|
| 139 |
+
j["openalex"] = oa
|
| 140 |
+
j["oa_percentile"] = pct
|
| 141 |
+
if not j.get("issn") and oa:
|
| 142 |
+
j["issn"] = oa.get("issn_l") or (oa.get("issns") or [None])[0]
|
| 143 |
+
return j
|
| 144 |
+
|
| 145 |
+
return list(await asyncio.gather(*[_enrich_one(j) for j in journals]))
|
prompts.py
CHANGED
|
@@ -3,7 +3,7 @@ ResearchBee β system prompts for all API endpoints.
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
# ββ Journal analysis ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
-
JOURNAL_SYSTEM_PROMPT = """You are ResearchBee, an expert academic publishing advisor
|
| 7 |
|
| 8 |
Return ONLY valid JSON in this exact structure:
|
| 9 |
{
|
|
@@ -17,45 +17,34 @@ Return ONLY valid JSON in this exact structure:
|
|
| 17 |
"journals": [
|
| 18 |
{
|
| 19 |
"name": "Journal name",
|
| 20 |
-
"issn": "ISSN if known",
|
| 21 |
"publisher": "Publisher name",
|
| 22 |
"confidence": "High|Medium|Low",
|
| 23 |
"fit_reason": "Why this journal fits",
|
| 24 |
-
"audience_match": "How audience aligns",
|
| 25 |
-
"submission_strategy": "Specific submission advice",
|
| 26 |
-
"oa_compliance_note": "
|
| 27 |
-
"green_oa": {
|
| 28 |
-
"policy_status": "Confirmed|Partially confirmed|Not confirmed",
|
| 29 |
-
"preprint": {"allowed": "Yes|No|Unclear", "where": "", "embargo": "", "licence": "", "conditions": ""},
|
| 30 |
-
"postprint": {"allowed": "Yes|No|Unclear", "where": "", "embargo": "", "licence": "", "conditions": ""},
|
| 31 |
-
"published_version": {"allowed": "Yes|No|Unclear", "where": "", "embargo": "", "licence": "", "conditions": ""},
|
| 32 |
-
"licence_notes": "",
|
| 33 |
-
"repository_action_note": "",
|
| 34 |
-
"evidence_note": "",
|
| 35 |
-
"risk_flag": null
|
| 36 |
-
},
|
| 37 |
-
|
| 38 |
}
|
| 39 |
],
|
| 40 |
"extended_list": [
|
| 41 |
{"name": "", "issn": "", "publisher": "", "fit_reason": ""}
|
| 42 |
],
|
| 43 |
-
"
|
| 44 |
-
"best_version_to_deposit": "",
|
| 45 |
-
"best_timing": "",
|
| 46 |
-
"immediate_open_deposit_possible": "Yes|No",
|
| 47 |
-
"embargoed_deposit_needed": "Yes|No",
|
| 48 |
-
"metadata_only_first": "Yes|No",
|
| 49 |
-
"manual_checks_required": []
|
| 50 |
-
},
|
| 51 |
-
"next_actions": ["action 1", "action 2"],
|
| 52 |
"global_notes": ""
|
| 53 |
}
|
| 54 |
|
| 55 |
Return 10 detailed journals in 'journals' and up to 20 quick matches in 'extended_list'.
|
| 56 |
-
Do NOT include submission_checklist in the response
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
# ββ Licence checking ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
LICENSE_SYSTEM_PROMPT = """You are ResearchBee, a scholarly communication assistant at Khalifa University Library.
|
|
@@ -68,12 +57,13 @@ confirmed.
|
|
| 68 |
ABSOLUTE RULES:
|
| 69 |
1. NEVER state a specific embargo period, licence, or permitted version as fact.
|
| 70 |
2. ALWAYS set "policy_status" to "Not confirmed".
|
| 71 |
-
3.
|
|
|
|
| 72 |
themselves supplied the information, in which case attribute it to them.
|
| 73 |
-
|
| 74 |
-
|
| 75 |
answer the policy question yourself.
|
| 76 |
-
|
| 77 |
|
| 78 |
Return ONLY valid JSON:
|
| 79 |
{
|
|
@@ -84,22 +74,26 @@ Return ONLY valid JSON:
|
|
| 84 |
"publisher": "only if supplied by the user, else empty string",
|
| 85 |
"green_oa": {
|
| 86 |
"policy_status": "Not confirmed",
|
|
|
|
|
|
|
| 87 |
"preprint": {"allowed": "Unclear", "where": "", "embargo": "", "licence": "", "conditions": ""},
|
| 88 |
"postprint": {"allowed": "Unclear", "where": "", "embargo": "", "licence": "", "conditions": ""},
|
| 89 |
"published_version": {"allowed": "Unclear", "where": "", "embargo": "", "licence": "", "conditions": ""},
|
| 90 |
"licence_notes": "",
|
| 91 |
"repository_action_note": "Practical guidance on what to check and where.",
|
| 92 |
"evidence_note": "No verified policy record was retrieved for this journal.",
|
| 93 |
-
"risk_flag": "Policy not verified
|
| 94 |
}
|
| 95 |
}
|
| 96 |
],
|
| 97 |
"repository_recommendation": {
|
|
|
|
|
|
|
| 98 |
"best_version_to_deposit": "Not determined",
|
| 99 |
-
"best_timing": "Not determined",
|
| 100 |
-
"immediate_open_deposit_possible": "
|
| 101 |
-
"embargoed_deposit_needed": "
|
| 102 |
-
"metadata_only_first": "
|
| 103 |
"manual_checks_required": ["Verify the policy at Open Policy Finder before depositing."]
|
| 104 |
},
|
| 105 |
"next_actions": ["Enter your article's DOI for a verified, article-level answer."],
|
|
@@ -137,20 +131,21 @@ Return ONLY valid JSON: {"note": "..."}"""
|
|
| 137 |
REPO_SYSTEM_PROMPT = """You are ResearchBee, a research data management advisor at Khalifa University Library.
|
| 138 |
|
| 139 |
CRITICAL: You will be given a CURATED REGISTRY of verified repositories.
|
| 140 |
-
You must SELECT from that registry by "id". You must NOT invent
|
| 141 |
-
and
|
| 142 |
-
certification, cost,
|
| 143 |
-
|
|
|
|
| 144 |
|
| 145 |
Your job is ONLY to:
|
| 146 |
* understand the dataset,
|
| 147 |
-
* choose the 4
|
| 148 |
-
* write a
|
|
|
|
| 149 |
|
| 150 |
If no registry entry fits well, return fewer entries rather than inventing one.
|
| 151 |
|
| 152 |
-
Return ONLY valid JSON:
|
| 153 |
-
|
| 154 |
{
|
| 155 |
"dataset_understanding": {
|
| 156 |
"summary": "",
|
|
@@ -162,32 +157,15 @@ Return ONLY valid JSON:
|
|
| 162 |
},
|
| 163 |
"repositories": [
|
| 164 |
{
|
| 165 |
-
"
|
| 166 |
-
"
|
| 167 |
-
"
|
| 168 |
-
"scope": "",
|
| 169 |
-
"fit_reason": "",
|
| 170 |
-
"data_types_accepted": "",
|
| 171 |
-
"max_file_size": "",
|
| 172 |
-
"licences_supported": "",
|
| 173 |
-
"persistent_identifier": "DOI|Handle|ARK|None",
|
| 174 |
-
"versioning": "Yes|No",
|
| 175 |
-
"embargo_support": "Yes|No",
|
| 176 |
-
"access_model": "Open|Controlled|Mixed",
|
| 177 |
-
"cost": "Free|Paid|Institutional",
|
| 178 |
-
"certification": "CoreTrust Seal|FAIR|Institutional|None",
|
| 179 |
-
"fair_alignment": "High|Medium|Low",
|
| 180 |
-
"funder_compliance_note": "",
|
| 181 |
-
"sensitive_data_suitable": "Yes|No|Conditional",
|
| 182 |
-
"confidence": "High|Medium|Low",
|
| 183 |
-
"verification_status": "",
|
| 184 |
-
"risk_flag": null
|
| 185 |
}
|
| 186 |
],
|
| 187 |
"deposit_strategy": {
|
| 188 |
-
"
|
| 189 |
-
"
|
| 190 |
-
"metadata_standard": "",
|
| 191 |
"deposit_workflow": [],
|
| 192 |
"manual_checks_required": []
|
| 193 |
},
|
|
@@ -196,10 +174,13 @@ Return ONLY valid JSON:
|
|
| 196 |
}
|
| 197 |
|
| 198 |
Rules:
|
| 199 |
-
-
|
| 200 |
-
-
|
| 201 |
-
-
|
| 202 |
-
-
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
# ββ Subject normalisation βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 205 |
SUBJECT_NORMALISE_PROMPT = """You are an academic subject classification expert. Map the user's subject input onto SCImago's classification.
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
# ββ Journal analysis ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
+
JOURNAL_SYSTEM_PROMPT = """You are ResearchBee, an expert academic publishing advisor. Analyse the manuscript and recommend journals based on scope, audience, article type, methods, and the author's stated preferences.
|
| 7 |
|
| 8 |
Return ONLY valid JSON in this exact structure:
|
| 9 |
{
|
|
|
|
| 17 |
"journals": [
|
| 18 |
{
|
| 19 |
"name": "Journal name",
|
| 20 |
+
"issn": "ISSN if known, otherwise empty",
|
| 21 |
"publisher": "Publisher name",
|
| 22 |
"confidence": "High|Medium|Low",
|
| 23 |
"fit_reason": "Why this journal fits",
|
| 24 |
+
"audience_match": "How the journal audience aligns",
|
| 25 |
+
"submission_strategy": "Specific submission advice that does not claim acceptance probability",
|
| 26 |
+
"oa_compliance_note": "General OA/funder planning note only; direct the author to ResearchBee License Checking with the article DOI for deposit rights"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
| 28 |
],
|
| 29 |
"extended_list": [
|
| 30 |
{"name": "", "issn": "", "publisher": "", "fit_reason": ""}
|
| 31 |
],
|
| 32 |
+
"next_actions": ["submission-planning action 1", "submission-planning action 2"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
"global_notes": ""
|
| 34 |
}
|
| 35 |
|
| 36 |
Return 10 detailed journals in 'journals' and up to 20 quick matches in 'extended_list'.
|
| 37 |
+
Do NOT include submission_checklist in the response.
|
| 38 |
+
|
| 39 |
+
POLICY SAFETY β ABSOLUTE RULES:
|
| 40 |
+
- This workflow DOES NOT check publisher or journal self-archiving policies.
|
| 41 |
+
- Do NOT return a green_oa object or repository_recommendation object.
|
| 42 |
+
- Do NOT state or imply a permitted manuscript version, embargo duration, licence, deposit location, or immediate-deposit right for any journal.
|
| 43 |
+
- Do NOT label any policy as confirmed or partially confirmed.
|
| 44 |
+
- The oa_compliance_note may discuss general OA/funder planning only and must tell the author to use ResearchBee's DOI-based License Checking tool before depositing any version.
|
| 45 |
+
- For KU authors, you may mention Khazna only as an institutional repository to consider AFTER the permitted version and timing have been verified.
|
| 46 |
+
- Recommend real journals only; never invent an ISSN. If uncertain, leave ISSN empty.
|
| 47 |
+
"""
|
| 48 |
|
| 49 |
# ββ Licence checking ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 50 |
LICENSE_SYSTEM_PROMPT = """You are ResearchBee, a scholarly communication assistant at Khalifa University Library.
|
|
|
|
| 57 |
ABSOLUTE RULES:
|
| 58 |
1. NEVER state a specific embargo period, licence, or permitted version as fact.
|
| 59 |
2. ALWAYS set "policy_status" to "Not confirmed".
|
| 60 |
+
3. ALWAYS set "permission_status" and "embargo_status" to "unknown".
|
| 61 |
+
4. Set every version block to {"allowed": "Unclear", ...} unless the user
|
| 62 |
themselves supplied the information, in which case attribute it to them.
|
| 63 |
+
5. NEVER invent an ISSN. If you were not given one, leave it empty.
|
| 64 |
+
6. Your job is to explain WHAT the author should check and WHERE, not to
|
| 65 |
answer the policy question yourself.
|
| 66 |
+
7. Recommend entering the article DOI for a verified answer.
|
| 67 |
|
| 68 |
Return ONLY valid JSON:
|
| 69 |
{
|
|
|
|
| 74 |
"publisher": "only if supplied by the user, else empty string",
|
| 75 |
"green_oa": {
|
| 76 |
"policy_status": "Not confirmed",
|
| 77 |
+
"permission_status": "unknown",
|
| 78 |
+
"embargo_status": "unknown",
|
| 79 |
"preprint": {"allowed": "Unclear", "where": "", "embargo": "", "licence": "", "conditions": ""},
|
| 80 |
"postprint": {"allowed": "Unclear", "where": "", "embargo": "", "licence": "", "conditions": ""},
|
| 81 |
"published_version": {"allowed": "Unclear", "where": "", "embargo": "", "licence": "", "conditions": ""},
|
| 82 |
"licence_notes": "",
|
| 83 |
"repository_action_note": "Practical guidance on what to check and where.",
|
| 84 |
"evidence_note": "No verified policy record was retrieved for this journal.",
|
| 85 |
+
"risk_flag": "Policy not verified β confirm before depositing."
|
| 86 |
}
|
| 87 |
}
|
| 88 |
],
|
| 89 |
"repository_recommendation": {
|
| 90 |
+
"permission_status": "unknown",
|
| 91 |
+
"embargo_status": "unknown",
|
| 92 |
"best_version_to_deposit": "Not determined",
|
| 93 |
+
"best_timing": "Not determined β verify",
|
| 94 |
+
"immediate_open_deposit_possible": "Unknown",
|
| 95 |
+
"embargoed_deposit_needed": "Unknown",
|
| 96 |
+
"metadata_only_first": "Unknown",
|
| 97 |
"manual_checks_required": ["Verify the policy at Open Policy Finder before depositing."]
|
| 98 |
},
|
| 99 |
"next_actions": ["Enter your article's DOI for a verified, article-level answer."],
|
|
|
|
| 131 |
REPO_SYSTEM_PROMPT = """You are ResearchBee, a research data management advisor at Khalifa University Library.
|
| 132 |
|
| 133 |
CRITICAL: You will be given a CURATED REGISTRY of verified repositories.
|
| 134 |
+
You must SELECT from that registry by the exact "id" value. You must NOT invent
|
| 135 |
+
repositories and must NOT state factual repository attributes such as URLs,
|
| 136 |
+
file-size limits, licences, certification, cost, identifiers, access models,
|
| 137 |
+
embargo support, versioning, or sensitive-data capability. The server supplies
|
| 138 |
+
all factual attributes from verified registry records.
|
| 139 |
|
| 140 |
Your job is ONLY to:
|
| 141 |
* understand the dataset,
|
| 142 |
+
* choose the 4β6 most suitable registry entries by exact id, in rank order,
|
| 143 |
+
* write a dataset-specific fit_reason and fit_confidence for each choice,
|
| 144 |
+
* provide general deposit-planning advice without inventing repository facts.
|
| 145 |
|
| 146 |
If no registry entry fits well, return fewer entries rather than inventing one.
|
| 147 |
|
| 148 |
+
Return ONLY valid JSON in this exact structure:
|
|
|
|
| 149 |
{
|
| 150 |
"dataset_understanding": {
|
| 151 |
"summary": "",
|
|
|
|
| 157 |
},
|
| 158 |
"repositories": [
|
| 159 |
{
|
| 160 |
+
"id": "exact registry id",
|
| 161 |
+
"fit_reason": "Why this registry entry suits this dataset",
|
| 162 |
+
"fit_confidence": "High|Medium|Low"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
}
|
| 164 |
],
|
| 165 |
"deposit_strategy": {
|
| 166 |
+
"primary_repository_id": "exact id from repositories, or empty",
|
| 167 |
+
"secondary_repository_id": "exact id from repositories, or empty",
|
| 168 |
+
"metadata_standard": "General recommendation; say 'Check repository guidance' when not established by the input",
|
| 169 |
"deposit_workflow": [],
|
| 170 |
"manual_checks_required": []
|
| 171 |
},
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
Rules:
|
| 177 |
+
- Every repositories item MUST contain an exact registry id.
|
| 178 |
+
- Return only the three repository fields shown above: id, fit_reason, fit_confidence.
|
| 179 |
+
- Prefer a suitable domain-specific repository and include one or two generalist alternatives when the registry supports that choice.
|
| 180 |
+
- Khazna is prepended by the server automatically; do not select it merely to satisfy the requested count.
|
| 181 |
+
- If the dataset contains identifiable, pseudonymised, health, or otherwise sensitive data, choose only registry entries whose supplied description explicitly supports the required access model; otherwise return fewer choices and flag a manual check.
|
| 182 |
+
- If no funder mandate is supplied, favour discoverability and disciplinary norms, but do not invent compliance claims.
|
| 183 |
+
"""
|
| 184 |
|
| 185 |
# ββ Subject normalisation βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 186 |
SUBJECT_NORMALISE_PROMPT = """You are an academic subject classification expert. Map the user's subject input onto SCImago's classification.
|
repositories.py
CHANGED
|
@@ -302,13 +302,24 @@ REGISTRY: dict[str, dict[str, Any]] = {
|
|
| 302 |
|
| 303 |
|
| 304 |
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 305 |
-
#
|
| 306 |
-
#
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
|
| 313 |
|
| 314 |
def registry_for_prompt(limit: int = 0) -> str:
|
|
@@ -326,21 +337,38 @@ def registry_for_prompt(limit: int = 0) -> str:
|
|
| 326 |
|
| 327 |
def hydrate_selection(selected: list[dict]) -> list[dict]:
|
| 328 |
"""
|
| 329 |
-
|
| 330 |
|
| 331 |
-
|
| 332 |
-
|
|
|
|
| 333 |
"""
|
| 334 |
out: list[dict] = []
|
| 335 |
seen: set[str] = set()
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
rid = str(item.get("id", "")).strip().lower()
|
| 338 |
entry = REGISTRY.get(rid)
|
| 339 |
if not entry or rid in seen:
|
| 340 |
continue
|
| 341 |
seen.add(rid)
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
merged["verified_note"] = (
|
| 345 |
f"Repository details verified {entry['verified']} from {entry['source_url']}"
|
| 346 |
)
|
|
@@ -348,11 +376,52 @@ def hydrate_selection(selected: list[dict]) -> list[dict]:
|
|
| 348 |
return out
|
| 349 |
|
| 350 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
def khazna_entry() -> dict:
|
| 352 |
e = dict(REGISTRY["khazna"])
|
| 353 |
e["fit_reason"] = ("Khazna is Khalifa University's institutional repository. "
|
| 354 |
"Register your dataset here for KU visibility and compliance, "
|
| 355 |
"even when the data itself lives in a domain repository.")
|
|
|
|
|
|
|
|
|
|
| 356 |
e["verified_note"] = (f"Repository details verified {e['verified']} "
|
| 357 |
f"from {e['source_url']}")
|
| 358 |
return e
|
|
|
|
| 302 |
|
| 303 |
|
| 304 |
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 305 |
+
# The model may author ONLY analytical fit fields. Every other repository
|
| 306 |
+
# attribute is supplied from REGISTRY, including the id itself after lookup.
|
| 307 |
+
MODEL_ALLOWED = ("fit_reason", "fit_confidence")
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def _clean_text(value: Any, limit: int = 1000) -> str:
|
| 311 |
+
return " ".join(str(value or "").split())[:limit]
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def _clean_list(value: Any, limit: int = 12, item_limit: int = 500) -> list[str]:
|
| 315 |
+
if not isinstance(value, list):
|
| 316 |
+
return []
|
| 317 |
+
out: list[str] = []
|
| 318 |
+
for item in value[:limit]:
|
| 319 |
+
cleaned = _clean_text(item, item_limit)
|
| 320 |
+
if cleaned:
|
| 321 |
+
out.append(cleaned)
|
| 322 |
+
return out
|
| 323 |
|
| 324 |
|
| 325 |
def registry_for_prompt(limit: int = 0) -> str:
|
|
|
|
| 337 |
|
| 338 |
def hydrate_selection(selected: list[dict]) -> list[dict]:
|
| 339 |
"""
|
| 340 |
+
Hydrate exact registry ids and discard every model-authored factual field.
|
| 341 |
|
| 342 |
+
Only `fit_reason` and `fit_confidence` survive from the model. Unknown ids,
|
| 343 |
+
duplicate ids, non-object entries, and malformed confidence values are
|
| 344 |
+
dropped or normalised rather than displayed as repository facts.
|
| 345 |
"""
|
| 346 |
out: list[dict] = []
|
| 347 |
seen: set[str] = set()
|
| 348 |
+
if not isinstance(selected, list):
|
| 349 |
+
return out
|
| 350 |
+
|
| 351 |
+
for item in selected:
|
| 352 |
+
if not isinstance(item, dict):
|
| 353 |
+
continue
|
| 354 |
rid = str(item.get("id", "")).strip().lower()
|
| 355 |
entry = REGISTRY.get(rid)
|
| 356 |
if not entry or rid in seen:
|
| 357 |
continue
|
| 358 |
seen.add(rid)
|
| 359 |
+
|
| 360 |
+
fit_reason = _clean_text(item.get("fit_reason"), 1200)
|
| 361 |
+
confidence = _clean_text(item.get("fit_confidence"), 20).title()
|
| 362 |
+
if confidence not in {"High", "Medium", "Low"}:
|
| 363 |
+
confidence = ""
|
| 364 |
+
|
| 365 |
+
merged = dict(entry)
|
| 366 |
+
merged["fit_reason"] = fit_reason
|
| 367 |
+
merged["fit_confidence"] = confidence
|
| 368 |
+
# Backwards-compatible alias for older frontends; it is still an
|
| 369 |
+
# analytical fit score, never a repository-fact confidence score.
|
| 370 |
+
merged["confidence"] = confidence
|
| 371 |
+
merged["verification_status"] = "Curated KU Library registry record"
|
| 372 |
merged["verified_note"] = (
|
| 373 |
f"Repository details verified {entry['verified']} from {entry['source_url']}"
|
| 374 |
)
|
|
|
|
| 376 |
return out
|
| 377 |
|
| 378 |
|
| 379 |
+
def hydrate_deposit_strategy(raw: Any, hydrated: list[dict]) -> dict:
|
| 380 |
+
"""Resolve model-selected ids to server-owned names and bound free text."""
|
| 381 |
+
raw = raw if isinstance(raw, dict) else {}
|
| 382 |
+
by_id = {str(r.get("id", "")): r for r in hydrated if r.get("id")}
|
| 383 |
+
ranked_ids = [
|
| 384 |
+
str(r.get("id")) for r in hydrated
|
| 385 |
+
if r.get("id") and not r.get("is_khazna")
|
| 386 |
+
]
|
| 387 |
+
|
| 388 |
+
def selected_id(key: str, fallback_index: int) -> str:
|
| 389 |
+
candidate = str(raw.get(key, "")).strip().lower()
|
| 390 |
+
if candidate in by_id and not by_id[candidate].get("is_khazna"):
|
| 391 |
+
return candidate
|
| 392 |
+
return ranked_ids[fallback_index] if len(ranked_ids) > fallback_index else ""
|
| 393 |
+
|
| 394 |
+
primary_id = selected_id("primary_repository_id", 0)
|
| 395 |
+
secondary_id = selected_id("secondary_repository_id", 1)
|
| 396 |
+
if secondary_id == primary_id:
|
| 397 |
+
secondary_id = ranked_ids[1] if len(ranked_ids) > 1 else ""
|
| 398 |
+
|
| 399 |
+
return {
|
| 400 |
+
"primary_repository_id": primary_id,
|
| 401 |
+
"secondary_repository_id": secondary_id,
|
| 402 |
+
"primary_choice": by_id.get(primary_id, {}).get("name", "Not determined"),
|
| 403 |
+
"secondary_choice": by_id.get(secondary_id, {}).get("name", "Not determined"),
|
| 404 |
+
"metadata_standard": _clean_text(
|
| 405 |
+
raw.get("metadata_standard") or "Check the selected repository's current metadata guidance.",
|
| 406 |
+
500,
|
| 407 |
+
),
|
| 408 |
+
"deposit_workflow": _clean_list(raw.get("deposit_workflow"), limit=10),
|
| 409 |
+
"manual_checks_required": _clean_list(raw.get("manual_checks_required"), limit=10),
|
| 410 |
+
"khazna_metadata_note": (
|
| 411 |
+
"Register the dataset metadata in Khazna for KU visibility even when "
|
| 412 |
+
"the files are deposited in a domain or generalist repository."
|
| 413 |
+
),
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
|
| 417 |
def khazna_entry() -> dict:
|
| 418 |
e = dict(REGISTRY["khazna"])
|
| 419 |
e["fit_reason"] = ("Khazna is Khalifa University's institutional repository. "
|
| 420 |
"Register your dataset here for KU visibility and compliance, "
|
| 421 |
"even when the data itself lives in a domain repository.")
|
| 422 |
+
e["fit_confidence"] = "High"
|
| 423 |
+
e["confidence"] = "High"
|
| 424 |
+
e["verification_status"] = "Curated KU Library registry record"
|
| 425 |
e["verified_note"] = (f"Repository details verified {e['verified']} "
|
| 426 |
f"from {e['source_url']}")
|
| 427 |
return e
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
pytest>=8.0,<10
|
| 3 |
+
pytest-asyncio>=0.23,<2
|
requirements.txt
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
-
fastapi==0.111.0
|
| 2 |
-
uvicorn[standard]==0.29.0
|
| 3 |
-
openai==1.30.0
|
| 4 |
-
httpx==0.27.0
|
| 5 |
-
python-multipart==0.0.9
|
| 6 |
-
tenacity==8.3.0
|
| 7 |
-
huggingface_hub>=0.23
|
| 8 |
-
apscheduler>=3.10
|
| 9 |
-
joblib>=1.3
|
| 10 |
-
numpy>=1.26
|
| 11 |
-
scipy>=1.11
|
| 12 |
-
scikit-learn>=1.4
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn[standard]==0.29.0
|
| 3 |
+
openai==1.30.0
|
| 4 |
+
httpx==0.27.0
|
| 5 |
+
python-multipart==0.0.9
|
| 6 |
+
tenacity==8.3.0
|
| 7 |
+
huggingface_hub>=0.23
|
| 8 |
+
apscheduler>=3.10
|
| 9 |
+
joblib>=1.3
|
| 10 |
+
numpy>=1.26
|
| 11 |
+
scipy>=1.11
|
| 12 |
+
scikit-learn>=1.4
|
researchbee_journals.py
CHANGED
|
@@ -1,606 +1,606 @@
|
|
| 1 |
-
"""
|
| 2 |
-
ResearchBee journal index + recommender.
|
| 3 |
-
|
| 4 |
-
from researchbee_journals import JournalIndex
|
| 5 |
-
|
| 6 |
-
idx = JournalIndex("journals.db")
|
| 7 |
-
idx.lookup(issn="0737-8831") # -> JournalRecord (Library Hi Tech)
|
| 8 |
-
idx.recommend(title=..., abstract=...) # -> [Recommendation, ...]
|
| 9 |
-
|
| 10 |
-
Architecture
|
| 11 |
-
------------
|
| 12 |
-
Three stages, deliberately separated so the expensive one stays optional:
|
| 13 |
-
|
| 14 |
-
1. CANDIDATE GENERATION β hard ASJC filter (when subject codes are known) plus
|
| 15 |
-
FTS5/BM25 over title + publisher + subject names. Pure SQLite, no model,
|
| 16 |
-
sub-10ms, returns a few hundred candidates.
|
| 17 |
-
|
| 18 |
-
2. SEMANTIC RERANK β optional. Cosine similarity between the manuscript
|
| 19 |
-
embedding and a per-journal centroid. See the honest caveat below.
|
| 20 |
-
|
| 21 |
-
3. FIT SCORING β quartile, indexation state, OA status, coverage
|
| 22 |
-
recency, language, publisher agreement coverage. Deterministic, explainable,
|
| 23 |
-
and the part that should drive the text ResearchBee shows the researcher.
|
| 24 |
-
|
| 25 |
-
The caveat on stage 2
|
| 26 |
-
---------------------
|
| 27 |
-
The Scopus Source List carries no aims-and-scope text. Embedding a journal
|
| 28 |
-
*title* against a manuscript *abstract* is a weak signal β it recovers obvious
|
| 29 |
-
lexical matches ("Journal of Hydrology" for a hydrology paper) and little else.
|
| 30 |
-
Real matchers (Jane, Elsevier Journal Finder, WoS Match Manuscript) compare the
|
| 31 |
-
manuscript against *articles the journal has published*. To reach that quality
|
| 32 |
-
you need article-level text, which means building journal centroids from OpenAlex
|
| 33 |
-
works β `build_centroids.py` does this. Until centroids exist, `recommend()`
|
| 34 |
-
runs stages 1 and 3 only and says so in `Recommendation.method`, rather than
|
| 35 |
-
quietly returning title-similarity noise dressed up as semantic matching.
|
| 36 |
-
"""
|
| 37 |
-
from __future__ import annotations
|
| 38 |
-
|
| 39 |
-
import re
|
| 40 |
-
import sqlite3
|
| 41 |
-
import threading
|
| 42 |
-
from dataclasses import dataclass, field, asdict
|
| 43 |
-
from pathlib import Path
|
| 44 |
-
from typing import Iterable, Sequence
|
| 45 |
-
|
| 46 |
-
__all__ = ["JournalIndex", "JournalRecord", "Recommendation", "IndexationState"]
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
# ---------------------------------------------------------------------------
|
| 50 |
-
# Indexation state β the distinction the old lookup collapsed
|
| 51 |
-
# ---------------------------------------------------------------------------
|
| 52 |
-
class IndexationState:
|
| 53 |
-
"""
|
| 54 |
-
Why this exists: 'no quartile' is three materially different answers to a
|
| 55 |
-
researcher deciding where to submit.
|
| 56 |
-
|
| 57 |
-
INDEXED_RANKED in Scopus, has a current SJR and quartile. (30,405)
|
| 58 |
-
INDEXED_NEW in Scopus, coverage began 2024+, SJR not yet computed.
|
| 59 |
-
SJR needs a ~3-year citation window, so the absence is a
|
| 60 |
-
timing artefact, not a quality signal. Safe to submit to.
|
| 61 |
-
(326)
|
| 62 |
-
INDEXED_UNRANKED SCImago has a record but computes no SJR β typically below
|
| 63 |
-
its citable-document threshold. Thin but real. (279)
|
| 64 |
-
INDEXED_NO_SJR in Scopus, absent from SCImago entirely. Overwhelmingly
|
| 65 |
-
book series (1,052), which SJR does not rank at all. Only
|
| 66 |
-
~70 are long-running journals, mostly regional titles.
|
| 67 |
-
(1,366)
|
| 68 |
-
NOT_INDEXED no Scopus source record at all, active or inactive.
|
| 69 |
-
|
| 70 |
-
And three states for titles Scopus no longer indexes. Keeping them apart is
|
| 71 |
-
the whole point: of 16,838 inactive titles only 967 were removed for cause.
|
| 72 |
-
Telling a researcher that a journal which merely changed its name is a
|
| 73 |
-
quality risk is worse than saying nothing.
|
| 74 |
-
|
| 75 |
-
DISCONTINUED removed by Scopus for cause. The real red flag. (967)
|
| 76 |
-
RENAMED ceased under this title but the work continues elsewhere;
|
| 77 |
-
`successor_title` names where. Benign β redirect, don't
|
| 78 |
-
warn. (~7,000 with a resolvable successor)
|
| 79 |
-
CEASED stopped being indexed, no successor recorded and no
|
| 80 |
-
discontinuation flag. Usually a journal that folded.
|
| 81 |
-
"""
|
| 82 |
-
INDEXED_RANKED = "indexed_ranked"
|
| 83 |
-
INDEXED_NEW = "indexed_new"
|
| 84 |
-
INDEXED_UNRANKED = "indexed_unranked"
|
| 85 |
-
INDEXED_NO_SJR = "indexed_no_sjr"
|
| 86 |
-
NOT_INDEXED = "not_indexed"
|
| 87 |
-
DISCONTINUED = "discontinued"
|
| 88 |
-
RENAMED = "renamed"
|
| 89 |
-
CEASED = "ceased"
|
| 90 |
-
|
| 91 |
-
#: States a prospective author should be actively warned about.
|
| 92 |
-
WARN = frozenset({DISCONTINUED})
|
| 93 |
-
#: States meaning "Scopus does not index new articles here".
|
| 94 |
-
NOT_CURRENTLY_INDEXED = frozenset({DISCONTINUED, RENAMED, CEASED, NOT_INDEXED})
|
| 95 |
-
|
| 96 |
-
MESSAGES = {
|
| 97 |
-
INDEXED_RANKED: "Indexed in Scopus with a current SJR quartile.",
|
| 98 |
-
INDEXED_NEW: (
|
| 99 |
-
"Indexed in Scopus, but coverage is too recent for an SJR quartile. "
|
| 100 |
-
"SJR requires roughly three years of citation data; expect a first "
|
| 101 |
-
"quartile assignment once that window closes. The missing quartile "
|
| 102 |
-
"is not a quality signal."
|
| 103 |
-
),
|
| 104 |
-
INDEXED_UNRANKED: (
|
| 105 |
-
"Indexed in Scopus and listed by SCImago, but no SJR is computed β "
|
| 106 |
-
"usually because the title publishes too few citable documents to "
|
| 107 |
-
"meet SCImago's threshold."
|
| 108 |
-
),
|
| 109 |
-
INDEXED_NO_SJR: (
|
| 110 |
-
"Indexed in Scopus but absent from SCImago. Normal for book series "
|
| 111 |
-
"and trade journals, which SJR does not rank. Check the source type "
|
| 112 |
-
"before treating this as a concern."
|
| 113 |
-
),
|
| 114 |
-
NOT_INDEXED: (
|
| 115 |
-
"No Scopus source record found β this title is not indexed in "
|
| 116 |
-
"Scopus, and has no record of having been."
|
| 117 |
-
),
|
| 118 |
-
DISCONTINUED: (
|
| 119 |
-
"Scopus has discontinued this title β it was removed from the index "
|
| 120 |
-
"rather than ceasing publication. Articles published here will not "
|
| 121 |
-
"be indexed in Scopus. Scopus does not publish the reason for an "
|
| 122 |
-
"individual discontinuation; grounds range from publication-ethics "
|
| 123 |
-
"concerns to metrics such as self-citation, and discontinued titles "
|
| 124 |
-
"include some from major publishers. Verify independently before "
|
| 125 |
-
"submitting, and do not assume the journal is predatory."
|
| 126 |
-
),
|
| 127 |
-
RENAMED: (
|
| 128 |
-
"This title is no longer indexed under this name, but the journal "
|
| 129 |
-
"continues. This is a rename or merger, not a quality concern."
|
| 130 |
-
),
|
| 131 |
-
CEASED: (
|
| 132 |
-
"Scopus no longer indexes this title and no successor is recorded β "
|
| 133 |
-
"the journal appears to have ceased publication. Not flagged as a "
|
| 134 |
-
"discontinuation for cause."
|
| 135 |
-
),
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
_ISSN_RE = re.compile(r"[^0-9X]")
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def norm_issn(v: str | None) -> str | None:
|
| 143 |
-
if not v:
|
| 144 |
-
return None
|
| 145 |
-
s = _ISSN_RE.sub("", str(v).strip().upper())
|
| 146 |
-
if len(s) < 8:
|
| 147 |
-
s = s.zfill(8)
|
| 148 |
-
return s if len(s) == 8 else None
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
# ---------------------------------------------------------------------------
|
| 152 |
-
# Records
|
| 153 |
-
# ---------------------------------------------------------------------------
|
| 154 |
-
@dataclass
|
| 155 |
-
class JournalRecord:
|
| 156 |
-
scopus_id: str
|
| 157 |
-
title: str
|
| 158 |
-
issn_print: str | None
|
| 159 |
-
issn_e: str | None
|
| 160 |
-
source_type: str | None
|
| 161 |
-
publisher: str | None
|
| 162 |
-
publisher_group: str | None
|
| 163 |
-
coverage_start: int | None
|
| 164 |
-
coverage_end: int | None
|
| 165 |
-
coverage_has_gap: bool
|
| 166 |
-
oa_unpaywall: bool
|
| 167 |
-
medline: bool
|
| 168 |
-
has_english: bool
|
| 169 |
-
languages: str | None
|
| 170 |
-
sjr: float | None
|
| 171 |
-
sjr_rank: int | None
|
| 172 |
-
sjr_best_quartile: str | None
|
| 173 |
-
h_index: int | None
|
| 174 |
-
cites_per_doc_2y: float | None
|
| 175 |
-
total_docs_year: int | None
|
| 176 |
-
oa_diamond: bool
|
| 177 |
-
scimago_oa: bool
|
| 178 |
-
overton: int | None
|
| 179 |
-
country: str | None
|
| 180 |
-
scimago_id: str | None = None
|
| 181 |
-
lifecycle: str = "active"
|
| 182 |
-
successor_title: str | None = None
|
| 183 |
-
successor_scopus_id: str | None = None
|
| 184 |
-
successor_is_active: bool = False
|
| 185 |
-
subjects: list[dict] = field(default_factory=list)
|
| 186 |
-
state: str = IndexationState.NOT_INDEXED
|
| 187 |
-
state_message: str = ""
|
| 188 |
-
|
| 189 |
-
@property
|
| 190 |
-
def warn(self) -> bool:
|
| 191 |
-
"""True when a prospective author should be actively cautioned."""
|
| 192 |
-
return self.state in IndexationState.WARN
|
| 193 |
-
|
| 194 |
-
@property
|
| 195 |
-
def currently_indexed(self) -> bool:
|
| 196 |
-
return self.state not in IndexationState.NOT_CURRENTLY_INDEXED
|
| 197 |
-
|
| 198 |
-
def quartile_for(self, asjc: str) -> str | None:
|
| 199 |
-
"""Quartile in one specific subject area, not the journal's best."""
|
| 200 |
-
for s in self.subjects:
|
| 201 |
-
if s["asjc"] == asjc:
|
| 202 |
-
return s.get("quartile")
|
| 203 |
-
return None
|
| 204 |
-
|
| 205 |
-
def to_dict(self) -> dict:
|
| 206 |
-
return asdict(self)
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
@dataclass
|
| 210 |
-
class Recommendation:
|
| 211 |
-
journal: JournalRecord
|
| 212 |
-
score: float
|
| 213 |
-
reasons: list[str]
|
| 214 |
-
method: str
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
# ---------------------------------------------------------------------------
|
| 218 |
-
# Index
|
| 219 |
-
# ---------------------------------------------------------------------------
|
| 220 |
-
_STOP = set("""
|
| 221 |
-
a an the and or but if of in on at to for with by from as is are was were be been
|
| 222 |
-
being this that these those it its we our they their he she his her you your not
|
| 223 |
-
no can may might will would should could have has had do does did than then so
|
| 224 |
-
such other others new novel study studies paper article research results method
|
| 225 |
-
methods approach based using used use show shows shown propose proposed present
|
| 226 |
-
presented provide provides between among during through both each more most many
|
| 227 |
-
much also however therefore thus while when where which who whom what how why
|
| 228 |
-
""".split())
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
class JournalIndex:
|
| 232 |
-
"""Thread-safe read-only wrapper over journals.db."""
|
| 233 |
-
|
| 234 |
-
def __init__(self, db_path: str | Path):
|
| 235 |
-
self.db_path = str(db_path)
|
| 236 |
-
if not Path(self.db_path).exists():
|
| 237 |
-
raise FileNotFoundError(self.db_path)
|
| 238 |
-
self._local = threading.local()
|
| 239 |
-
|
| 240 |
-
@property
|
| 241 |
-
def con(self) -> sqlite3.Connection:
|
| 242 |
-
con = getattr(self._local, "con", None)
|
| 243 |
-
if con is None:
|
| 244 |
-
con = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True,
|
| 245 |
-
check_same_thread=False)
|
| 246 |
-
con.row_factory = sqlite3.Row
|
| 247 |
-
con.execute("PRAGMA mmap_size = 268435456")
|
| 248 |
-
self._local.con = con
|
| 249 |
-
return con
|
| 250 |
-
|
| 251 |
-
# -- meta ---------------------------------------------------------------
|
| 252 |
-
def meta(self) -> dict[str, str]:
|
| 253 |
-
return {r["key"]: r["value"] for r in self.con.execute("SELECT * FROM meta")}
|
| 254 |
-
|
| 255 |
-
# -- lookup -------------------------------------------------------------
|
| 256 |
-
def lookup(self, issn: str | None = None, title: str | None = None,
|
| 257 |
-
scopus_id: str | None = None) -> JournalRecord | None:
|
| 258 |
-
row = None
|
| 259 |
-
if scopus_id:
|
| 260 |
-
row = self.con.execute(
|
| 261 |
-
"SELECT * FROM journals WHERE scopus_id = ?", (scopus_id,)
|
| 262 |
-
).fetchone()
|
| 263 |
-
if row is None and issn:
|
| 264 |
-
key = norm_issn(issn)
|
| 265 |
-
if key:
|
| 266 |
-
row = self.con.execute(
|
| 267 |
-
"SELECT * FROM journals WHERE issn_e = ? OR issn_print = ? LIMIT 1",
|
| 268 |
-
(key, key),
|
| 269 |
-
).fetchone()
|
| 270 |
-
if row is None and title:
|
| 271 |
-
row = self.con.execute(
|
| 272 |
-
"SELECT * FROM journals WHERE lower(title) = lower(?) LIMIT 1",
|
| 273 |
-
(title.strip(),),
|
| 274 |
-
).fetchone()
|
| 275 |
-
if row is None:
|
| 276 |
-
hits = self._fts(title, limit=1, weights=self._W_LOOKUP)
|
| 277 |
-
if hits:
|
| 278 |
-
row = self.con.execute(
|
| 279 |
-
"SELECT * FROM journals WHERE scopus_id = ?", (hits[0][0],)
|
| 280 |
-
).fetchone()
|
| 281 |
-
return self._to_record(row) if row else None
|
| 282 |
-
|
| 283 |
-
def _to_record(self, row: sqlite3.Row) -> JournalRecord:
|
| 284 |
-
subs = [
|
| 285 |
-
{"asjc": r["asjc"], "name": r["name"], "parent": r["parent_name"],
|
| 286 |
-
"quartile": r["quartile"], "is_primary": bool(r["is_primary"])}
|
| 287 |
-
for r in self.con.execute(
|
| 288 |
-
"""SELECT ja.asjc, ja.is_primary, ja.quartile, a.name, a.parent_name
|
| 289 |
-
FROM journal_asjc ja JOIN asjc a ON a.code = ja.asjc
|
| 290 |
-
WHERE ja.scopus_id = ? ORDER BY ja.is_primary DESC, ja.asjc""",
|
| 291 |
-
(row["scopus_id"],),
|
| 292 |
-
)
|
| 293 |
-
]
|
| 294 |
-
rec = JournalRecord(
|
| 295 |
-
scopus_id=row["scopus_id"], title=row["title"],
|
| 296 |
-
issn_print=self._fmt(row["issn_print"]), issn_e=self._fmt(row["issn_e"]),
|
| 297 |
-
source_type=row["source_type"], publisher=row["publisher"],
|
| 298 |
-
publisher_group=row["publisher_group"],
|
| 299 |
-
coverage_start=row["coverage_start"], coverage_end=row["coverage_end"],
|
| 300 |
-
coverage_has_gap=bool(row["coverage_has_gap"]),
|
| 301 |
-
oa_unpaywall=bool(row["oa_unpaywall"]), medline=bool(row["medline"]),
|
| 302 |
-
has_english=bool(row["has_english"]), languages=row["languages"],
|
| 303 |
-
sjr=row["sjr"], sjr_rank=row["sjr_rank"],
|
| 304 |
-
sjr_best_quartile=row["sjr_best_quartile"], h_index=row["h_index"],
|
| 305 |
-
cites_per_doc_2y=row["cites_per_doc_2y"],
|
| 306 |
-
total_docs_year=row["total_docs_year"],
|
| 307 |
-
oa_diamond=bool(row["oa_diamond"]),
|
| 308 |
-
scimago_oa=bool(row["scimago_oa"]), overton=row["overton"],
|
| 309 |
-
country=row["country"], scimago_id=row["scimago_id"],
|
| 310 |
-
lifecycle=row["lifecycle"],
|
| 311 |
-
successor_title=row["successor_title"],
|
| 312 |
-
successor_scopus_id=row["successor_scopus_id"],
|
| 313 |
-
successor_is_active=bool(row["successor_is_active"]),
|
| 314 |
-
subjects=subs,
|
| 315 |
-
)
|
| 316 |
-
rec.state = self._state(row)
|
| 317 |
-
rec.state_message = self._message(rec)
|
| 318 |
-
return rec
|
| 319 |
-
|
| 320 |
-
@staticmethod
|
| 321 |
-
def _message(rec: "JournalRecord") -> str:
|
| 322 |
-
"""Base message, extended with the successor when we resolved one."""
|
| 323 |
-
msg = IndexationState.MESSAGES[rec.state]
|
| 324 |
-
if rec.successor_title:
|
| 325 |
-
where = (
|
| 326 |
-
"which is actively indexed"
|
| 327 |
-
if rec.successor_is_active
|
| 328 |
-
else "which is itself no longer indexed"
|
| 329 |
-
)
|
| 330 |
-
if rec.state == IndexationState.RENAMED:
|
| 331 |
-
msg += f" It continues as {rec.successor_title!r}, {where}."
|
| 332 |
-
elif rec.state in (IndexationState.DISCONTINUED, IndexationState.CEASED):
|
| 333 |
-
# A successor on a discontinued title does not soften the
|
| 334 |
-
# warning β it just tells the researcher where to look next.
|
| 335 |
-
msg += f" Scopus records a successor title, {rec.successor_title!r}, {where}."
|
| 336 |
-
return msg
|
| 337 |
-
|
| 338 |
-
@staticmethod
|
| 339 |
-
def _fmt(s: str | None) -> str | None:
|
| 340 |
-
return f"{s[:4]}-{s[4:]}" if s and len(s) == 8 else None
|
| 341 |
-
|
| 342 |
-
_STATE_BY_STATUS = {
|
| 343 |
-
"matched": IndexationState.INDEXED_RANKED,
|
| 344 |
-
"no_sjr_new_title": IndexationState.INDEXED_NEW,
|
| 345 |
-
"no_sjr_listed_unranked": IndexationState.INDEXED_UNRANKED,
|
| 346 |
-
"no_sjr_unmatched": IndexationState.INDEXED_NO_SJR,
|
| 347 |
-
}
|
| 348 |
-
|
| 349 |
-
_STATE_BY_LIFECYCLE = {
|
| 350 |
-
"discontinued": IndexationState.DISCONTINUED,
|
| 351 |
-
"renamed": IndexationState.RENAMED,
|
| 352 |
-
"ceased": IndexationState.CEASED,
|
| 353 |
-
}
|
| 354 |
-
|
| 355 |
-
@classmethod
|
| 356 |
-
def _state(cls, row: sqlite3.Row) -> str:
|
| 357 |
-
# Lifecycle wins over SJR status. A discontinued title may still carry a
|
| 358 |
-
# quartile from when it was indexed, and reporting that quartile as the
|
| 359 |
-
# headline would bury the thing the researcher actually needs to know.
|
| 360 |
-
lc = cls._STATE_BY_LIFECYCLE.get(row["lifecycle"])
|
| 361 |
-
if lc:
|
| 362 |
-
return lc
|
| 363 |
-
return cls._STATE_BY_STATUS.get(
|
| 364 |
-
row["sjr_status"], IndexationState.INDEXED_NO_SJR
|
| 365 |
-
)
|
| 366 |
-
|
| 367 |
-
# -- candidate generation ----------------------------------------------
|
| 368 |
-
# bm25 column weights: (scopus_id, title, publisher_group, subjects).
|
| 369 |
-
# Publisher is weighted to zero for topical work β "University Library
|
| 370 |
-
# System" is not evidence that a cinema journal wants your library paper,
|
| 371 |
-
# and leaving it at 1.0 lets one publisher flood the candidate pool. It
|
| 372 |
-
# stays queryable for name lookups via _W_LOOKUP.
|
| 373 |
-
_W_TOPIC = (0.0, 4.0, 0.0, 3.0)
|
| 374 |
-
_W_LOOKUP = (0.0, 8.0, 1.0, 0.5)
|
| 375 |
-
|
| 376 |
-
def _fts(self, text: str, limit: int = 400,
|
| 377 |
-
asjc: Sequence[str] | None = None,
|
| 378 |
-
weights: tuple[float, ...] | None = None,
|
| 379 |
-
active_only: bool = False) -> list[tuple[str, float]]:
|
| 380 |
-
terms = self._terms(text)
|
| 381 |
-
if not terms:
|
| 382 |
-
return []
|
| 383 |
-
w = weights or self._W_TOPIC
|
| 384 |
-
query = " OR ".join(f'"{t}"' for t in terms)
|
| 385 |
-
sql = f"""
|
| 386 |
-
SELECT f.scopus_id AS sid,
|
| 387 |
-
bm25(journals_fts, {w[0]}, {w[1]}, {w[2]}, {w[3]}) AS rank
|
| 388 |
-
FROM journals_fts f WHERE journals_fts MATCH ?
|
| 389 |
-
"""
|
| 390 |
-
params: list = [query]
|
| 391 |
-
if active_only:
|
| 392 |
-
# Applied inside candidate generation, not after: the index holds
|
| 393 |
-
# 16,838 inactive titles, and letting them consume the candidate
|
| 394 |
-
# pool would silently shrink the number of real options considered.
|
| 395 |
-
sql += " AND f.scopus_id IN (SELECT scopus_id FROM journals WHERE active = 1)"
|
| 396 |
-
if asjc:
|
| 397 |
-
marks = ",".join("?" * len(asjc))
|
| 398 |
-
sql += f""" AND f.scopus_id IN (
|
| 399 |
-
SELECT scopus_id FROM journal_asjc WHERE asjc IN ({marks}))"""
|
| 400 |
-
params += list(asjc)
|
| 401 |
-
sql += " ORDER BY rank LIMIT ?"
|
| 402 |
-
params.append(limit)
|
| 403 |
-
return [(r["sid"], r["rank"]) for r in self.con.execute(sql, params)]
|
| 404 |
-
|
| 405 |
-
@staticmethod
|
| 406 |
-
def _terms(text: str, max_terms: int = 24) -> list[str]:
|
| 407 |
-
"""
|
| 408 |
-
FTS5 treats bare punctuation as syntax, so terms are stripped to word
|
| 409 |
-
characters and quoted. Frequency-ordered, stopword-filtered.
|
| 410 |
-
"""
|
| 411 |
-
from collections import Counter
|
| 412 |
-
words = re.findall(r"[A-Za-z][A-Za-z\-]{2,}", (text or "").lower())
|
| 413 |
-
words = [w.strip("-") for w in words if w not in _STOP and len(w) > 3]
|
| 414 |
-
return [w for w, _ in Counter(words).most_common(max_terms)]
|
| 415 |
-
|
| 416 |
-
# -- recommend ----------------------------------------------------------
|
| 417 |
-
def recommend(
|
| 418 |
-
self,
|
| 419 |
-
title: str = "",
|
| 420 |
-
abstract: str = "",
|
| 421 |
-
asjc: Sequence[str] | None = None,
|
| 422 |
-
*,
|
| 423 |
-
limit: int = 20,
|
| 424 |
-
candidate_pool: int = 400,
|
| 425 |
-
require_english: bool = True,
|
| 426 |
-
require_oa: bool = False,
|
| 427 |
-
exclude_book_series: bool = True,
|
| 428 |
-
min_quartile: str | None = None,
|
| 429 |
-
publisher_agreements: Iterable[str] = (),
|
| 430 |
-
embedder=None,
|
| 431 |
-
centroids=None,
|
| 432 |
-
asjc_classifier=None,
|
| 433 |
-
) -> list[Recommendation]:
|
| 434 |
-
"""
|
| 435 |
-
`asjc` β subject codes to hard-filter on. Supply these when you know the
|
| 436 |
-
manuscript's field; recall from free text alone is mediocre.
|
| 437 |
-
`min_quartile` β 'Q1' or 'Q2'. Applied against the quartile in the
|
| 438 |
-
filtered subject area when `asjc` is given, otherwise against
|
| 439 |
-
the journal's best quartile.
|
| 440 |
-
`publisher_agreements` β publisher_group names the institution holds a
|
| 441 |
-
read-and-publish deal with; matches get a scoring bonus and an
|
| 442 |
-
explicit reason string.
|
| 443 |
-
`embedder`/`centroids` β optional stage 2. See module docstring.
|
| 444 |
-
`asjc_classifier` β an AsjcClassifier. When `asjc` is not supplied, the
|
| 445 |
-
subject codes are inferred from the manuscript rather than
|
| 446 |
-
left empty. This matters more than any other option here: the
|
| 447 |
-
subject filter is what makes the results usable, and an
|
| 448 |
-
unfiltered free-text search returns unrelated journals.
|
| 449 |
-
"""
|
| 450 |
-
inferred = None
|
| 451 |
-
if not asjc and asjc_classifier is not None:
|
| 452 |
-
inferred = asjc_classifier.classify(title=title, abstract=abstract)
|
| 453 |
-
asjc = inferred.codes or None
|
| 454 |
-
|
| 455 |
-
text = f"{title} {title} {abstract}".strip()
|
| 456 |
-
cands = self._fts(text, limit=candidate_pool, asjc=asjc, active_only=True)
|
| 457 |
-
if not cands and asjc:
|
| 458 |
-
marks = ",".join("?" * len(asjc))
|
| 459 |
-
cands = [
|
| 460 |
-
(r["scopus_id"], 0.0)
|
| 461 |
-
for r in self.con.execute(
|
| 462 |
-
f"""SELECT DISTINCT j.scopus_id FROM journals j
|
| 463 |
-
JOIN journal_asjc ja ON ja.scopus_id = j.scopus_id
|
| 464 |
-
WHERE ja.asjc IN ({marks}) AND j.active = 1
|
| 465 |
-
ORDER BY j.sjr DESC LIMIT ?""",
|
| 466 |
-
(*asjc, candidate_pool),
|
| 467 |
-
)
|
| 468 |
-
]
|
| 469 |
-
if not cands:
|
| 470 |
-
return []
|
| 471 |
-
|
| 472 |
-
ranks = [r for _, r in cands]
|
| 473 |
-
lo, hi = min(ranks), max(ranks)
|
| 474 |
-
span = (hi - lo) or 1.0
|
| 475 |
-
|
| 476 |
-
method = "lexical+fit"
|
| 477 |
-
sem: dict[str, float] = {}
|
| 478 |
-
if embedder is not None and centroids is not None:
|
| 479 |
-
sem = self._semantic(text, [c for c, _ in cands], embedder, centroids)
|
| 480 |
-
if sem:
|
| 481 |
-
method = "lexical+semantic+fit"
|
| 482 |
-
|
| 483 |
-
out: list[Recommendation] = []
|
| 484 |
-
agreements = {p.lower() for p in publisher_agreements}
|
| 485 |
-
for sid, rank in cands:
|
| 486 |
-
row = self.con.execute(
|
| 487 |
-
"SELECT * FROM journals WHERE scopus_id = ?", (sid,)
|
| 488 |
-
).fetchone()
|
| 489 |
-
if row is None:
|
| 490 |
-
continue
|
| 491 |
-
if exclude_book_series and row["source_type"] != "Journal":
|
| 492 |
-
continue
|
| 493 |
-
if require_english and not row["has_english"]:
|
| 494 |
-
continue
|
| 495 |
-
if require_oa and not row["oa_unpaywall"]:
|
| 496 |
-
continue
|
| 497 |
-
# Never recommend somewhere Scopus no longer indexes β discontinued,
|
| 498 |
-
# renamed or ceased. Lookup still returns these (a researcher asking
|
| 499 |
-
# about a specific title needs the answer); recommendation must not.
|
| 500 |
-
if not row["active"]:
|
| 501 |
-
continue
|
| 502 |
-
|
| 503 |
-
rec = self._to_record(row)
|
| 504 |
-
q = rec.quartile_for(asjc[0]) if asjc else rec.sjr_best_quartile
|
| 505 |
-
q = q or rec.sjr_best_quartile
|
| 506 |
-
if min_quartile and (q is None or q > min_quartile):
|
| 507 |
-
continue
|
| 508 |
-
|
| 509 |
-
# bm25 returns lower-is-better; invert to 0..1
|
| 510 |
-
lex = 1.0 - ((rank - lo) / span)
|
| 511 |
-
score = 0.45 * lex
|
| 512 |
-
reasons: list[str] = []
|
| 513 |
-
|
| 514 |
-
if sem:
|
| 515 |
-
score = 0.30 * lex + 0.30 * sem.get(sid, 0.0)
|
| 516 |
-
|
| 517 |
-
qbonus = {"Q1": 0.25, "Q2": 0.18, "Q3": 0.10, "Q4": 0.04}.get(q or "", 0.0)
|
| 518 |
-
score += qbonus
|
| 519 |
-
if q:
|
| 520 |
-
label = f"{q} in {self._asjc_name(asjc[0])}" if asjc else f"{q} (best quartile)"
|
| 521 |
-
reasons.append(label)
|
| 522 |
-
elif rec.state == IndexationState.INDEXED_NEW:
|
| 523 |
-
score += 0.06
|
| 524 |
-
reasons.append("Newly indexed β no quartile yet, not a quality signal")
|
| 525 |
-
|
| 526 |
-
if rec.coverage_end and rec.coverage_end >= 2026:
|
| 527 |
-
score += 0.05
|
| 528 |
-
else:
|
| 529 |
-
reasons.append(f"Coverage appears to end {rec.coverage_end} β verify status")
|
| 530 |
-
|
| 531 |
-
if rec.oa_diamond:
|
| 532 |
-
# Diamond = no APC and no subscription. For an author without
|
| 533 |
-
# grant funding this outranks quartile as a practical criterion,
|
| 534 |
-
# so it is weighted above the generic OA flag.
|
| 535 |
-
score += 0.10
|
| 536 |
-
reasons.append("Diamond open access β no APC")
|
| 537 |
-
elif rec.oa_unpaywall:
|
| 538 |
-
score += 0.06
|
| 539 |
-
reasons.append("Open access")
|
| 540 |
-
|
| 541 |
-
if rec.publisher_group and rec.publisher_group.lower() in agreements:
|
| 542 |
-
score += 0.12
|
| 543 |
-
reasons.append(f"Covered by your {rec.publisher_group} agreement β APC likely waived")
|
| 544 |
-
|
| 545 |
-
if rec.coverage_has_gap:
|
| 546 |
-
score -= 0.04
|
| 547 |
-
reasons.append("Discontinuous Scopus coverage")
|
| 548 |
-
|
| 549 |
-
if rec.medline:
|
| 550 |
-
reasons.append("Medline-sourced")
|
| 551 |
-
|
| 552 |
-
out.append(Recommendation(rec, round(score, 4), reasons, method))
|
| 553 |
-
|
| 554 |
-
out.sort(key=lambda r: r.score, reverse=True)
|
| 555 |
-
out = out[:limit]
|
| 556 |
-
|
| 557 |
-
# An inferred subject filter silently determines the entire result set,
|
| 558 |
-
# so it is never left implicit: every result says which subject was
|
| 559 |
-
# inferred and how, giving the user something concrete to correct when
|
| 560 |
-
# the classifier picked the wrong field.
|
| 561 |
-
if inferred is not None:
|
| 562 |
-
note = f"Subject inferred as {', '.join(inferred.names)} ({inferred.method})"
|
| 563 |
-
for r in out:
|
| 564 |
-
r.reasons.append(note)
|
| 565 |
-
r.method = f"{r.method}+asjc:{inferred.method}"
|
| 566 |
-
return out
|
| 567 |
-
|
| 568 |
-
def _asjc_name(self, code: str) -> str:
|
| 569 |
-
r = self.con.execute("SELECT name, parent_name FROM asjc WHERE code = ?",
|
| 570 |
-
(code,)).fetchone()
|
| 571 |
-
return (r["name"] or r["parent_name"]) if r else code
|
| 572 |
-
|
| 573 |
-
@staticmethod
|
| 574 |
-
def _semantic(text, sids, embedder, centroids) -> dict[str, float]:
|
| 575 |
-
import numpy as np
|
| 576 |
-
v = np.asarray(embedder(text), dtype="float32").ravel()
|
| 577 |
-
v /= (np.linalg.norm(v) or 1.0)
|
| 578 |
-
got = {s: centroids[s] for s in sids if s in centroids}
|
| 579 |
-
if not got:
|
| 580 |
-
return {}
|
| 581 |
-
keys = list(got)
|
| 582 |
-
M = np.vstack([got[k] for k in keys]).astype("float32")
|
| 583 |
-
M /= (np.linalg.norm(M, axis=1, keepdims=True) + 1e-9)
|
| 584 |
-
sims = M @ v
|
| 585 |
-
lo, hi = float(sims.min()), float(sims.max())
|
| 586 |
-
span = (hi - lo) or 1.0
|
| 587 |
-
return {k: float((s - lo) / span) for k, s in zip(keys, sims)}
|
| 588 |
-
|
| 589 |
-
# -- subject helpers ----------------------------------------------------
|
| 590 |
-
def find_asjc(self, query: str, limit: int = 10) -> list[dict]:
|
| 591 |
-
"""Resolve a free-text subject to ASJC codes, for populating the filter."""
|
| 592 |
-
like = f"%{query.strip().lower()}%"
|
| 593 |
-
return [
|
| 594 |
-
dict(r) for r in self.con.execute(
|
| 595 |
-
"""SELECT code, name, parent_code, parent_name, n_journals FROM asjc
|
| 596 |
-
WHERE lower(name) LIKE ? OR lower(parent_name) LIKE ?
|
| 597 |
-
ORDER BY n_journals DESC LIMIT ?""",
|
| 598 |
-
(like, like, limit),
|
| 599 |
-
)
|
| 600 |
-
]
|
| 601 |
-
|
| 602 |
-
def publisher_titles(self, publisher_group: str) -> int:
|
| 603 |
-
return self.con.execute(
|
| 604 |
-
"SELECT count(*) FROM journals WHERE publisher_group = ?",
|
| 605 |
-
(publisher_group,),
|
| 606 |
-
).fetchone()[0]
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ResearchBee journal index + recommender.
|
| 3 |
+
|
| 4 |
+
from researchbee_journals import JournalIndex
|
| 5 |
+
|
| 6 |
+
idx = JournalIndex("journals.db")
|
| 7 |
+
idx.lookup(issn="0737-8831") # -> JournalRecord (Library Hi Tech)
|
| 8 |
+
idx.recommend(title=..., abstract=...) # -> [Recommendation, ...]
|
| 9 |
+
|
| 10 |
+
Architecture
|
| 11 |
+
------------
|
| 12 |
+
Three stages, deliberately separated so the expensive one stays optional:
|
| 13 |
+
|
| 14 |
+
1. CANDIDATE GENERATION β hard ASJC filter (when subject codes are known) plus
|
| 15 |
+
FTS5/BM25 over title + publisher + subject names. Pure SQLite, no model,
|
| 16 |
+
sub-10ms, returns a few hundred candidates.
|
| 17 |
+
|
| 18 |
+
2. SEMANTIC RERANK β optional. Cosine similarity between the manuscript
|
| 19 |
+
embedding and a per-journal centroid. See the honest caveat below.
|
| 20 |
+
|
| 21 |
+
3. FIT SCORING β quartile, indexation state, OA status, coverage
|
| 22 |
+
recency, language, publisher agreement coverage. Deterministic, explainable,
|
| 23 |
+
and the part that should drive the text ResearchBee shows the researcher.
|
| 24 |
+
|
| 25 |
+
The caveat on stage 2
|
| 26 |
+
---------------------
|
| 27 |
+
The Scopus Source List carries no aims-and-scope text. Embedding a journal
|
| 28 |
+
*title* against a manuscript *abstract* is a weak signal β it recovers obvious
|
| 29 |
+
lexical matches ("Journal of Hydrology" for a hydrology paper) and little else.
|
| 30 |
+
Real matchers (Jane, Elsevier Journal Finder, WoS Match Manuscript) compare the
|
| 31 |
+
manuscript against *articles the journal has published*. To reach that quality
|
| 32 |
+
you need article-level text, which means building journal centroids from OpenAlex
|
| 33 |
+
works β `build_centroids.py` does this. Until centroids exist, `recommend()`
|
| 34 |
+
runs stages 1 and 3 only and says so in `Recommendation.method`, rather than
|
| 35 |
+
quietly returning title-similarity noise dressed up as semantic matching.
|
| 36 |
+
"""
|
| 37 |
+
from __future__ import annotations
|
| 38 |
+
|
| 39 |
+
import re
|
| 40 |
+
import sqlite3
|
| 41 |
+
import threading
|
| 42 |
+
from dataclasses import dataclass, field, asdict
|
| 43 |
+
from pathlib import Path
|
| 44 |
+
from typing import Iterable, Sequence
|
| 45 |
+
|
| 46 |
+
__all__ = ["JournalIndex", "JournalRecord", "Recommendation", "IndexationState"]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
# Indexation state β the distinction the old lookup collapsed
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
class IndexationState:
|
| 53 |
+
"""
|
| 54 |
+
Why this exists: 'no quartile' is three materially different answers to a
|
| 55 |
+
researcher deciding where to submit.
|
| 56 |
+
|
| 57 |
+
INDEXED_RANKED in Scopus, has a current SJR and quartile. (30,405)
|
| 58 |
+
INDEXED_NEW in Scopus, coverage began 2024+, SJR not yet computed.
|
| 59 |
+
SJR needs a ~3-year citation window, so the absence is a
|
| 60 |
+
timing artefact, not a quality signal. Safe to submit to.
|
| 61 |
+
(326)
|
| 62 |
+
INDEXED_UNRANKED SCImago has a record but computes no SJR β typically below
|
| 63 |
+
its citable-document threshold. Thin but real. (279)
|
| 64 |
+
INDEXED_NO_SJR in Scopus, absent from SCImago entirely. Overwhelmingly
|
| 65 |
+
book series (1,052), which SJR does not rank at all. Only
|
| 66 |
+
~70 are long-running journals, mostly regional titles.
|
| 67 |
+
(1,366)
|
| 68 |
+
NOT_INDEXED no Scopus source record at all, active or inactive.
|
| 69 |
+
|
| 70 |
+
And three states for titles Scopus no longer indexes. Keeping them apart is
|
| 71 |
+
the whole point: of 16,838 inactive titles only 967 were removed for cause.
|
| 72 |
+
Telling a researcher that a journal which merely changed its name is a
|
| 73 |
+
quality risk is worse than saying nothing.
|
| 74 |
+
|
| 75 |
+
DISCONTINUED removed by Scopus for cause. The real red flag. (967)
|
| 76 |
+
RENAMED ceased under this title but the work continues elsewhere;
|
| 77 |
+
`successor_title` names where. Benign β redirect, don't
|
| 78 |
+
warn. (~7,000 with a resolvable successor)
|
| 79 |
+
CEASED stopped being indexed, no successor recorded and no
|
| 80 |
+
discontinuation flag. Usually a journal that folded.
|
| 81 |
+
"""
|
| 82 |
+
INDEXED_RANKED = "indexed_ranked"
|
| 83 |
+
INDEXED_NEW = "indexed_new"
|
| 84 |
+
INDEXED_UNRANKED = "indexed_unranked"
|
| 85 |
+
INDEXED_NO_SJR = "indexed_no_sjr"
|
| 86 |
+
NOT_INDEXED = "not_indexed"
|
| 87 |
+
DISCONTINUED = "discontinued"
|
| 88 |
+
RENAMED = "renamed"
|
| 89 |
+
CEASED = "ceased"
|
| 90 |
+
|
| 91 |
+
#: States a prospective author should be actively warned about.
|
| 92 |
+
WARN = frozenset({DISCONTINUED})
|
| 93 |
+
#: States meaning "Scopus does not index new articles here".
|
| 94 |
+
NOT_CURRENTLY_INDEXED = frozenset({DISCONTINUED, RENAMED, CEASED, NOT_INDEXED})
|
| 95 |
+
|
| 96 |
+
MESSAGES = {
|
| 97 |
+
INDEXED_RANKED: "Indexed in Scopus with a current SJR quartile.",
|
| 98 |
+
INDEXED_NEW: (
|
| 99 |
+
"Indexed in Scopus, but coverage is too recent for an SJR quartile. "
|
| 100 |
+
"SJR requires roughly three years of citation data; expect a first "
|
| 101 |
+
"quartile assignment once that window closes. The missing quartile "
|
| 102 |
+
"is not a quality signal."
|
| 103 |
+
),
|
| 104 |
+
INDEXED_UNRANKED: (
|
| 105 |
+
"Indexed in Scopus and listed by SCImago, but no SJR is computed β "
|
| 106 |
+
"usually because the title publishes too few citable documents to "
|
| 107 |
+
"meet SCImago's threshold."
|
| 108 |
+
),
|
| 109 |
+
INDEXED_NO_SJR: (
|
| 110 |
+
"Indexed in Scopus but absent from SCImago. Normal for book series "
|
| 111 |
+
"and trade journals, which SJR does not rank. Check the source type "
|
| 112 |
+
"before treating this as a concern."
|
| 113 |
+
),
|
| 114 |
+
NOT_INDEXED: (
|
| 115 |
+
"No Scopus source record found β this title is not indexed in "
|
| 116 |
+
"Scopus, and has no record of having been."
|
| 117 |
+
),
|
| 118 |
+
DISCONTINUED: (
|
| 119 |
+
"Scopus has discontinued this title β it was removed from the index "
|
| 120 |
+
"rather than ceasing publication. Articles published here will not "
|
| 121 |
+
"be indexed in Scopus. Scopus does not publish the reason for an "
|
| 122 |
+
"individual discontinuation; grounds range from publication-ethics "
|
| 123 |
+
"concerns to metrics such as self-citation, and discontinued titles "
|
| 124 |
+
"include some from major publishers. Verify independently before "
|
| 125 |
+
"submitting, and do not assume the journal is predatory."
|
| 126 |
+
),
|
| 127 |
+
RENAMED: (
|
| 128 |
+
"This title is no longer indexed under this name, but the journal "
|
| 129 |
+
"continues. This is a rename or merger, not a quality concern."
|
| 130 |
+
),
|
| 131 |
+
CEASED: (
|
| 132 |
+
"Scopus no longer indexes this title and no successor is recorded β "
|
| 133 |
+
"the journal appears to have ceased publication. Not flagged as a "
|
| 134 |
+
"discontinuation for cause."
|
| 135 |
+
),
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
_ISSN_RE = re.compile(r"[^0-9X]")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def norm_issn(v: str | None) -> str | None:
|
| 143 |
+
if not v:
|
| 144 |
+
return None
|
| 145 |
+
s = _ISSN_RE.sub("", str(v).strip().upper())
|
| 146 |
+
if len(s) < 8:
|
| 147 |
+
s = s.zfill(8)
|
| 148 |
+
return s if len(s) == 8 else None
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ---------------------------------------------------------------------------
|
| 152 |
+
# Records
|
| 153 |
+
# ---------------------------------------------------------------------------
|
| 154 |
+
@dataclass
|
| 155 |
+
class JournalRecord:
|
| 156 |
+
scopus_id: str
|
| 157 |
+
title: str
|
| 158 |
+
issn_print: str | None
|
| 159 |
+
issn_e: str | None
|
| 160 |
+
source_type: str | None
|
| 161 |
+
publisher: str | None
|
| 162 |
+
publisher_group: str | None
|
| 163 |
+
coverage_start: int | None
|
| 164 |
+
coverage_end: int | None
|
| 165 |
+
coverage_has_gap: bool
|
| 166 |
+
oa_unpaywall: bool
|
| 167 |
+
medline: bool
|
| 168 |
+
has_english: bool
|
| 169 |
+
languages: str | None
|
| 170 |
+
sjr: float | None
|
| 171 |
+
sjr_rank: int | None
|
| 172 |
+
sjr_best_quartile: str | None
|
| 173 |
+
h_index: int | None
|
| 174 |
+
cites_per_doc_2y: float | None
|
| 175 |
+
total_docs_year: int | None
|
| 176 |
+
oa_diamond: bool
|
| 177 |
+
scimago_oa: bool
|
| 178 |
+
overton: int | None
|
| 179 |
+
country: str | None
|
| 180 |
+
scimago_id: str | None = None
|
| 181 |
+
lifecycle: str = "active"
|
| 182 |
+
successor_title: str | None = None
|
| 183 |
+
successor_scopus_id: str | None = None
|
| 184 |
+
successor_is_active: bool = False
|
| 185 |
+
subjects: list[dict] = field(default_factory=list)
|
| 186 |
+
state: str = IndexationState.NOT_INDEXED
|
| 187 |
+
state_message: str = ""
|
| 188 |
+
|
| 189 |
+
@property
|
| 190 |
+
def warn(self) -> bool:
|
| 191 |
+
"""True when a prospective author should be actively cautioned."""
|
| 192 |
+
return self.state in IndexationState.WARN
|
| 193 |
+
|
| 194 |
+
@property
|
| 195 |
+
def currently_indexed(self) -> bool:
|
| 196 |
+
return self.state not in IndexationState.NOT_CURRENTLY_INDEXED
|
| 197 |
+
|
| 198 |
+
def quartile_for(self, asjc: str) -> str | None:
|
| 199 |
+
"""Quartile in one specific subject area, not the journal's best."""
|
| 200 |
+
for s in self.subjects:
|
| 201 |
+
if s["asjc"] == asjc:
|
| 202 |
+
return s.get("quartile")
|
| 203 |
+
return None
|
| 204 |
+
|
| 205 |
+
def to_dict(self) -> dict:
|
| 206 |
+
return asdict(self)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@dataclass
|
| 210 |
+
class Recommendation:
|
| 211 |
+
journal: JournalRecord
|
| 212 |
+
score: float
|
| 213 |
+
reasons: list[str]
|
| 214 |
+
method: str
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ---------------------------------------------------------------------------
|
| 218 |
+
# Index
|
| 219 |
+
# ---------------------------------------------------------------------------
|
| 220 |
+
_STOP = set("""
|
| 221 |
+
a an the and or but if of in on at to for with by from as is are was were be been
|
| 222 |
+
being this that these those it its we our they their he she his her you your not
|
| 223 |
+
no can may might will would should could have has had do does did than then so
|
| 224 |
+
such other others new novel study studies paper article research results method
|
| 225 |
+
methods approach based using used use show shows shown propose proposed present
|
| 226 |
+
presented provide provides between among during through both each more most many
|
| 227 |
+
much also however therefore thus while when where which who whom what how why
|
| 228 |
+
""".split())
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
class JournalIndex:
|
| 232 |
+
"""Thread-safe read-only wrapper over journals.db."""
|
| 233 |
+
|
| 234 |
+
def __init__(self, db_path: str | Path):
|
| 235 |
+
self.db_path = str(db_path)
|
| 236 |
+
if not Path(self.db_path).exists():
|
| 237 |
+
raise FileNotFoundError(self.db_path)
|
| 238 |
+
self._local = threading.local()
|
| 239 |
+
|
| 240 |
+
@property
|
| 241 |
+
def con(self) -> sqlite3.Connection:
|
| 242 |
+
con = getattr(self._local, "con", None)
|
| 243 |
+
if con is None:
|
| 244 |
+
con = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True,
|
| 245 |
+
check_same_thread=False)
|
| 246 |
+
con.row_factory = sqlite3.Row
|
| 247 |
+
con.execute("PRAGMA mmap_size = 268435456")
|
| 248 |
+
self._local.con = con
|
| 249 |
+
return con
|
| 250 |
+
|
| 251 |
+
# -- meta ---------------------------------------------------------------
|
| 252 |
+
def meta(self) -> dict[str, str]:
|
| 253 |
+
return {r["key"]: r["value"] for r in self.con.execute("SELECT * FROM meta")}
|
| 254 |
+
|
| 255 |
+
# -- lookup -------------------------------------------------------------
|
| 256 |
+
def lookup(self, issn: str | None = None, title: str | None = None,
|
| 257 |
+
scopus_id: str | None = None) -> JournalRecord | None:
|
| 258 |
+
row = None
|
| 259 |
+
if scopus_id:
|
| 260 |
+
row = self.con.execute(
|
| 261 |
+
"SELECT * FROM journals WHERE scopus_id = ?", (scopus_id,)
|
| 262 |
+
).fetchone()
|
| 263 |
+
if row is None and issn:
|
| 264 |
+
key = norm_issn(issn)
|
| 265 |
+
if key:
|
| 266 |
+
row = self.con.execute(
|
| 267 |
+
"SELECT * FROM journals WHERE issn_e = ? OR issn_print = ? LIMIT 1",
|
| 268 |
+
(key, key),
|
| 269 |
+
).fetchone()
|
| 270 |
+
if row is None and title:
|
| 271 |
+
row = self.con.execute(
|
| 272 |
+
"SELECT * FROM journals WHERE lower(title) = lower(?) LIMIT 1",
|
| 273 |
+
(title.strip(),),
|
| 274 |
+
).fetchone()
|
| 275 |
+
if row is None:
|
| 276 |
+
hits = self._fts(title, limit=1, weights=self._W_LOOKUP)
|
| 277 |
+
if hits:
|
| 278 |
+
row = self.con.execute(
|
| 279 |
+
"SELECT * FROM journals WHERE scopus_id = ?", (hits[0][0],)
|
| 280 |
+
).fetchone()
|
| 281 |
+
return self._to_record(row) if row else None
|
| 282 |
+
|
| 283 |
+
def _to_record(self, row: sqlite3.Row) -> JournalRecord:
|
| 284 |
+
subs = [
|
| 285 |
+
{"asjc": r["asjc"], "name": r["name"], "parent": r["parent_name"],
|
| 286 |
+
"quartile": r["quartile"], "is_primary": bool(r["is_primary"])}
|
| 287 |
+
for r in self.con.execute(
|
| 288 |
+
"""SELECT ja.asjc, ja.is_primary, ja.quartile, a.name, a.parent_name
|
| 289 |
+
FROM journal_asjc ja JOIN asjc a ON a.code = ja.asjc
|
| 290 |
+
WHERE ja.scopus_id = ? ORDER BY ja.is_primary DESC, ja.asjc""",
|
| 291 |
+
(row["scopus_id"],),
|
| 292 |
+
)
|
| 293 |
+
]
|
| 294 |
+
rec = JournalRecord(
|
| 295 |
+
scopus_id=row["scopus_id"], title=row["title"],
|
| 296 |
+
issn_print=self._fmt(row["issn_print"]), issn_e=self._fmt(row["issn_e"]),
|
| 297 |
+
source_type=row["source_type"], publisher=row["publisher"],
|
| 298 |
+
publisher_group=row["publisher_group"],
|
| 299 |
+
coverage_start=row["coverage_start"], coverage_end=row["coverage_end"],
|
| 300 |
+
coverage_has_gap=bool(row["coverage_has_gap"]),
|
| 301 |
+
oa_unpaywall=bool(row["oa_unpaywall"]), medline=bool(row["medline"]),
|
| 302 |
+
has_english=bool(row["has_english"]), languages=row["languages"],
|
| 303 |
+
sjr=row["sjr"], sjr_rank=row["sjr_rank"],
|
| 304 |
+
sjr_best_quartile=row["sjr_best_quartile"], h_index=row["h_index"],
|
| 305 |
+
cites_per_doc_2y=row["cites_per_doc_2y"],
|
| 306 |
+
total_docs_year=row["total_docs_year"],
|
| 307 |
+
oa_diamond=bool(row["oa_diamond"]),
|
| 308 |
+
scimago_oa=bool(row["scimago_oa"]), overton=row["overton"],
|
| 309 |
+
country=row["country"], scimago_id=row["scimago_id"],
|
| 310 |
+
lifecycle=row["lifecycle"],
|
| 311 |
+
successor_title=row["successor_title"],
|
| 312 |
+
successor_scopus_id=row["successor_scopus_id"],
|
| 313 |
+
successor_is_active=bool(row["successor_is_active"]),
|
| 314 |
+
subjects=subs,
|
| 315 |
+
)
|
| 316 |
+
rec.state = self._state(row)
|
| 317 |
+
rec.state_message = self._message(rec)
|
| 318 |
+
return rec
|
| 319 |
+
|
| 320 |
+
@staticmethod
|
| 321 |
+
def _message(rec: "JournalRecord") -> str:
|
| 322 |
+
"""Base message, extended with the successor when we resolved one."""
|
| 323 |
+
msg = IndexationState.MESSAGES[rec.state]
|
| 324 |
+
if rec.successor_title:
|
| 325 |
+
where = (
|
| 326 |
+
"which is actively indexed"
|
| 327 |
+
if rec.successor_is_active
|
| 328 |
+
else "which is itself no longer indexed"
|
| 329 |
+
)
|
| 330 |
+
if rec.state == IndexationState.RENAMED:
|
| 331 |
+
msg += f" It continues as {rec.successor_title!r}, {where}."
|
| 332 |
+
elif rec.state in (IndexationState.DISCONTINUED, IndexationState.CEASED):
|
| 333 |
+
# A successor on a discontinued title does not soften the
|
| 334 |
+
# warning β it just tells the researcher where to look next.
|
| 335 |
+
msg += f" Scopus records a successor title, {rec.successor_title!r}, {where}."
|
| 336 |
+
return msg
|
| 337 |
+
|
| 338 |
+
@staticmethod
|
| 339 |
+
def _fmt(s: str | None) -> str | None:
|
| 340 |
+
return f"{s[:4]}-{s[4:]}" if s and len(s) == 8 else None
|
| 341 |
+
|
| 342 |
+
_STATE_BY_STATUS = {
|
| 343 |
+
"matched": IndexationState.INDEXED_RANKED,
|
| 344 |
+
"no_sjr_new_title": IndexationState.INDEXED_NEW,
|
| 345 |
+
"no_sjr_listed_unranked": IndexationState.INDEXED_UNRANKED,
|
| 346 |
+
"no_sjr_unmatched": IndexationState.INDEXED_NO_SJR,
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
_STATE_BY_LIFECYCLE = {
|
| 350 |
+
"discontinued": IndexationState.DISCONTINUED,
|
| 351 |
+
"renamed": IndexationState.RENAMED,
|
| 352 |
+
"ceased": IndexationState.CEASED,
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
@classmethod
|
| 356 |
+
def _state(cls, row: sqlite3.Row) -> str:
|
| 357 |
+
# Lifecycle wins over SJR status. A discontinued title may still carry a
|
| 358 |
+
# quartile from when it was indexed, and reporting that quartile as the
|
| 359 |
+
# headline would bury the thing the researcher actually needs to know.
|
| 360 |
+
lc = cls._STATE_BY_LIFECYCLE.get(row["lifecycle"])
|
| 361 |
+
if lc:
|
| 362 |
+
return lc
|
| 363 |
+
return cls._STATE_BY_STATUS.get(
|
| 364 |
+
row["sjr_status"], IndexationState.INDEXED_NO_SJR
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
# -- candidate generation ----------------------------------------------
|
| 368 |
+
# bm25 column weights: (scopus_id, title, publisher_group, subjects).
|
| 369 |
+
# Publisher is weighted to zero for topical work β "University Library
|
| 370 |
+
# System" is not evidence that a cinema journal wants your library paper,
|
| 371 |
+
# and leaving it at 1.0 lets one publisher flood the candidate pool. It
|
| 372 |
+
# stays queryable for name lookups via _W_LOOKUP.
|
| 373 |
+
_W_TOPIC = (0.0, 4.0, 0.0, 3.0)
|
| 374 |
+
_W_LOOKUP = (0.0, 8.0, 1.0, 0.5)
|
| 375 |
+
|
| 376 |
+
def _fts(self, text: str, limit: int = 400,
|
| 377 |
+
asjc: Sequence[str] | None = None,
|
| 378 |
+
weights: tuple[float, ...] | None = None,
|
| 379 |
+
active_only: bool = False) -> list[tuple[str, float]]:
|
| 380 |
+
terms = self._terms(text)
|
| 381 |
+
if not terms:
|
| 382 |
+
return []
|
| 383 |
+
w = weights or self._W_TOPIC
|
| 384 |
+
query = " OR ".join(f'"{t}"' for t in terms)
|
| 385 |
+
sql = f"""
|
| 386 |
+
SELECT f.scopus_id AS sid,
|
| 387 |
+
bm25(journals_fts, {w[0]}, {w[1]}, {w[2]}, {w[3]}) AS rank
|
| 388 |
+
FROM journals_fts f WHERE journals_fts MATCH ?
|
| 389 |
+
"""
|
| 390 |
+
params: list = [query]
|
| 391 |
+
if active_only:
|
| 392 |
+
# Applied inside candidate generation, not after: the index holds
|
| 393 |
+
# 16,838 inactive titles, and letting them consume the candidate
|
| 394 |
+
# pool would silently shrink the number of real options considered.
|
| 395 |
+
sql += " AND f.scopus_id IN (SELECT scopus_id FROM journals WHERE active = 1)"
|
| 396 |
+
if asjc:
|
| 397 |
+
marks = ",".join("?" * len(asjc))
|
| 398 |
+
sql += f""" AND f.scopus_id IN (
|
| 399 |
+
SELECT scopus_id FROM journal_asjc WHERE asjc IN ({marks}))"""
|
| 400 |
+
params += list(asjc)
|
| 401 |
+
sql += " ORDER BY rank LIMIT ?"
|
| 402 |
+
params.append(limit)
|
| 403 |
+
return [(r["sid"], r["rank"]) for r in self.con.execute(sql, params)]
|
| 404 |
+
|
| 405 |
+
@staticmethod
|
| 406 |
+
def _terms(text: str, max_terms: int = 24) -> list[str]:
|
| 407 |
+
"""
|
| 408 |
+
FTS5 treats bare punctuation as syntax, so terms are stripped to word
|
| 409 |
+
characters and quoted. Frequency-ordered, stopword-filtered.
|
| 410 |
+
"""
|
| 411 |
+
from collections import Counter
|
| 412 |
+
words = re.findall(r"[A-Za-z][A-Za-z\-]{2,}", (text or "").lower())
|
| 413 |
+
words = [w.strip("-") for w in words if w not in _STOP and len(w) > 3]
|
| 414 |
+
return [w for w, _ in Counter(words).most_common(max_terms)]
|
| 415 |
+
|
| 416 |
+
# -- recommend ----------------------------------------------------------
|
| 417 |
+
def recommend(
|
| 418 |
+
self,
|
| 419 |
+
title: str = "",
|
| 420 |
+
abstract: str = "",
|
| 421 |
+
asjc: Sequence[str] | None = None,
|
| 422 |
+
*,
|
| 423 |
+
limit: int = 20,
|
| 424 |
+
candidate_pool: int = 400,
|
| 425 |
+
require_english: bool = True,
|
| 426 |
+
require_oa: bool = False,
|
| 427 |
+
exclude_book_series: bool = True,
|
| 428 |
+
min_quartile: str | None = None,
|
| 429 |
+
publisher_agreements: Iterable[str] = (),
|
| 430 |
+
embedder=None,
|
| 431 |
+
centroids=None,
|
| 432 |
+
asjc_classifier=None,
|
| 433 |
+
) -> list[Recommendation]:
|
| 434 |
+
"""
|
| 435 |
+
`asjc` β subject codes to hard-filter on. Supply these when you know the
|
| 436 |
+
manuscript's field; recall from free text alone is mediocre.
|
| 437 |
+
`min_quartile` β 'Q1' or 'Q2'. Applied against the quartile in the
|
| 438 |
+
filtered subject area when `asjc` is given, otherwise against
|
| 439 |
+
the journal's best quartile.
|
| 440 |
+
`publisher_agreements` β publisher_group names the institution holds a
|
| 441 |
+
read-and-publish deal with; matches get a scoring bonus and an
|
| 442 |
+
explicit reason string.
|
| 443 |
+
`embedder`/`centroids` β optional stage 2. See module docstring.
|
| 444 |
+
`asjc_classifier` β an AsjcClassifier. When `asjc` is not supplied, the
|
| 445 |
+
subject codes are inferred from the manuscript rather than
|
| 446 |
+
left empty. This matters more than any other option here: the
|
| 447 |
+
subject filter is what makes the results usable, and an
|
| 448 |
+
unfiltered free-text search returns unrelated journals.
|
| 449 |
+
"""
|
| 450 |
+
inferred = None
|
| 451 |
+
if not asjc and asjc_classifier is not None:
|
| 452 |
+
inferred = asjc_classifier.classify(title=title, abstract=abstract)
|
| 453 |
+
asjc = inferred.codes or None
|
| 454 |
+
|
| 455 |
+
text = f"{title} {title} {abstract}".strip()
|
| 456 |
+
cands = self._fts(text, limit=candidate_pool, asjc=asjc, active_only=True)
|
| 457 |
+
if not cands and asjc:
|
| 458 |
+
marks = ",".join("?" * len(asjc))
|
| 459 |
+
cands = [
|
| 460 |
+
(r["scopus_id"], 0.0)
|
| 461 |
+
for r in self.con.execute(
|
| 462 |
+
f"""SELECT DISTINCT j.scopus_id FROM journals j
|
| 463 |
+
JOIN journal_asjc ja ON ja.scopus_id = j.scopus_id
|
| 464 |
+
WHERE ja.asjc IN ({marks}) AND j.active = 1
|
| 465 |
+
ORDER BY j.sjr DESC LIMIT ?""",
|
| 466 |
+
(*asjc, candidate_pool),
|
| 467 |
+
)
|
| 468 |
+
]
|
| 469 |
+
if not cands:
|
| 470 |
+
return []
|
| 471 |
+
|
| 472 |
+
ranks = [r for _, r in cands]
|
| 473 |
+
lo, hi = min(ranks), max(ranks)
|
| 474 |
+
span = (hi - lo) or 1.0
|
| 475 |
+
|
| 476 |
+
method = "lexical+fit"
|
| 477 |
+
sem: dict[str, float] = {}
|
| 478 |
+
if embedder is not None and centroids is not None:
|
| 479 |
+
sem = self._semantic(text, [c for c, _ in cands], embedder, centroids)
|
| 480 |
+
if sem:
|
| 481 |
+
method = "lexical+semantic+fit"
|
| 482 |
+
|
| 483 |
+
out: list[Recommendation] = []
|
| 484 |
+
agreements = {p.lower() for p in publisher_agreements}
|
| 485 |
+
for sid, rank in cands:
|
| 486 |
+
row = self.con.execute(
|
| 487 |
+
"SELECT * FROM journals WHERE scopus_id = ?", (sid,)
|
| 488 |
+
).fetchone()
|
| 489 |
+
if row is None:
|
| 490 |
+
continue
|
| 491 |
+
if exclude_book_series and row["source_type"] != "Journal":
|
| 492 |
+
continue
|
| 493 |
+
if require_english and not row["has_english"]:
|
| 494 |
+
continue
|
| 495 |
+
if require_oa and not row["oa_unpaywall"]:
|
| 496 |
+
continue
|
| 497 |
+
# Never recommend somewhere Scopus no longer indexes β discontinued,
|
| 498 |
+
# renamed or ceased. Lookup still returns these (a researcher asking
|
| 499 |
+
# about a specific title needs the answer); recommendation must not.
|
| 500 |
+
if not row["active"]:
|
| 501 |
+
continue
|
| 502 |
+
|
| 503 |
+
rec = self._to_record(row)
|
| 504 |
+
q = rec.quartile_for(asjc[0]) if asjc else rec.sjr_best_quartile
|
| 505 |
+
q = q or rec.sjr_best_quartile
|
| 506 |
+
if min_quartile and (q is None or q > min_quartile):
|
| 507 |
+
continue
|
| 508 |
+
|
| 509 |
+
# bm25 returns lower-is-better; invert to 0..1
|
| 510 |
+
lex = 1.0 - ((rank - lo) / span)
|
| 511 |
+
score = 0.45 * lex
|
| 512 |
+
reasons: list[str] = []
|
| 513 |
+
|
| 514 |
+
if sem:
|
| 515 |
+
score = 0.30 * lex + 0.30 * sem.get(sid, 0.0)
|
| 516 |
+
|
| 517 |
+
qbonus = {"Q1": 0.25, "Q2": 0.18, "Q3": 0.10, "Q4": 0.04}.get(q or "", 0.0)
|
| 518 |
+
score += qbonus
|
| 519 |
+
if q:
|
| 520 |
+
label = f"{q} in {self._asjc_name(asjc[0])}" if asjc else f"{q} (best quartile)"
|
| 521 |
+
reasons.append(label)
|
| 522 |
+
elif rec.state == IndexationState.INDEXED_NEW:
|
| 523 |
+
score += 0.06
|
| 524 |
+
reasons.append("Newly indexed β no quartile yet, not a quality signal")
|
| 525 |
+
|
| 526 |
+
if rec.coverage_end and rec.coverage_end >= 2026:
|
| 527 |
+
score += 0.05
|
| 528 |
+
else:
|
| 529 |
+
reasons.append(f"Coverage appears to end {rec.coverage_end} β verify status")
|
| 530 |
+
|
| 531 |
+
if rec.oa_diamond:
|
| 532 |
+
# Diamond = no APC and no subscription. For an author without
|
| 533 |
+
# grant funding this outranks quartile as a practical criterion,
|
| 534 |
+
# so it is weighted above the generic OA flag.
|
| 535 |
+
score += 0.10
|
| 536 |
+
reasons.append("Diamond open access β no APC")
|
| 537 |
+
elif rec.oa_unpaywall:
|
| 538 |
+
score += 0.06
|
| 539 |
+
reasons.append("Open access")
|
| 540 |
+
|
| 541 |
+
if rec.publisher_group and rec.publisher_group.lower() in agreements:
|
| 542 |
+
score += 0.12
|
| 543 |
+
reasons.append(f"Covered by your {rec.publisher_group} agreement β APC likely waived")
|
| 544 |
+
|
| 545 |
+
if rec.coverage_has_gap:
|
| 546 |
+
score -= 0.04
|
| 547 |
+
reasons.append("Discontinuous Scopus coverage")
|
| 548 |
+
|
| 549 |
+
if rec.medline:
|
| 550 |
+
reasons.append("Medline-sourced")
|
| 551 |
+
|
| 552 |
+
out.append(Recommendation(rec, round(score, 4), reasons, method))
|
| 553 |
+
|
| 554 |
+
out.sort(key=lambda r: r.score, reverse=True)
|
| 555 |
+
out = out[:limit]
|
| 556 |
+
|
| 557 |
+
# An inferred subject filter silently determines the entire result set,
|
| 558 |
+
# so it is never left implicit: every result says which subject was
|
| 559 |
+
# inferred and how, giving the user something concrete to correct when
|
| 560 |
+
# the classifier picked the wrong field.
|
| 561 |
+
if inferred is not None:
|
| 562 |
+
note = f"Subject inferred as {', '.join(inferred.names)} ({inferred.method})"
|
| 563 |
+
for r in out:
|
| 564 |
+
r.reasons.append(note)
|
| 565 |
+
r.method = f"{r.method}+asjc:{inferred.method}"
|
| 566 |
+
return out
|
| 567 |
+
|
| 568 |
+
def _asjc_name(self, code: str) -> str:
|
| 569 |
+
r = self.con.execute("SELECT name, parent_name FROM asjc WHERE code = ?",
|
| 570 |
+
(code,)).fetchone()
|
| 571 |
+
return (r["name"] or r["parent_name"]) if r else code
|
| 572 |
+
|
| 573 |
+
@staticmethod
|
| 574 |
+
def _semantic(text, sids, embedder, centroids) -> dict[str, float]:
|
| 575 |
+
import numpy as np
|
| 576 |
+
v = np.asarray(embedder(text), dtype="float32").ravel()
|
| 577 |
+
v /= (np.linalg.norm(v) or 1.0)
|
| 578 |
+
got = {s: centroids[s] for s in sids if s in centroids}
|
| 579 |
+
if not got:
|
| 580 |
+
return {}
|
| 581 |
+
keys = list(got)
|
| 582 |
+
M = np.vstack([got[k] for k in keys]).astype("float32")
|
| 583 |
+
M /= (np.linalg.norm(M, axis=1, keepdims=True) + 1e-9)
|
| 584 |
+
sims = M @ v
|
| 585 |
+
lo, hi = float(sims.min()), float(sims.max())
|
| 586 |
+
span = (hi - lo) or 1.0
|
| 587 |
+
return {k: float((s - lo) / span) for k, s in zip(keys, sims)}
|
| 588 |
+
|
| 589 |
+
# -- subject helpers ----------------------------------------------------
|
| 590 |
+
def find_asjc(self, query: str, limit: int = 10) -> list[dict]:
|
| 591 |
+
"""Resolve a free-text subject to ASJC codes, for populating the filter."""
|
| 592 |
+
like = f"%{query.strip().lower()}%"
|
| 593 |
+
return [
|
| 594 |
+
dict(r) for r in self.con.execute(
|
| 595 |
+
"""SELECT code, name, parent_code, parent_name, n_journals FROM asjc
|
| 596 |
+
WHERE lower(name) LIKE ? OR lower(parent_name) LIKE ?
|
| 597 |
+
ORDER BY n_journals DESC LIMIT ?""",
|
| 598 |
+
(like, like, limit),
|
| 599 |
+
)
|
| 600 |
+
]
|
| 601 |
+
|
| 602 |
+
def publisher_titles(self, publisher_group: str) -> int:
|
| 603 |
+
return self.con.execute(
|
| 604 |
+
"SELECT count(*) FROM journals WHERE publisher_group = ?",
|
| 605 |
+
(publisher_group,),
|
| 606 |
+
).fetchone()[0]
|
scimago_index.py
CHANGED
|
@@ -1,299 +1,299 @@
|
|
| 1 |
-
"""
|
| 2 |
-
SCImago CSV loader β builds in-memory ISSN β journal dict at startup.
|
| 3 |
-
Used PRIVATELY for quartile lookup only. Never exposed as branded data source in UI.
|
| 4 |
-
|
| 5 |
-
Changes from v1.0:
|
| 6 |
-
- _norm_issn removed β uses utils.norm_issn
|
| 7 |
-
- sourceid now included in ISSN index entries (was missing β broke verify_links)
|
| 8 |
-
"""
|
| 9 |
-
import csv
|
| 10 |
-
import re
|
| 11 |
-
import os
|
| 12 |
-
import difflib
|
| 13 |
-
from typing import Optional, List, Dict, Set, Tuple
|
| 14 |
-
from utils import norm_issn
|
| 15 |
-
|
| 16 |
-
_scimago_cache: dict = {}
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
# ββ Closed-vocabulary parsing βββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
-
#
|
| 21 |
-
# SCImago's Categories column looks like
|
| 22 |
-
# "astronomy and astrophysics (q1); space and planetary science (q1)"
|
| 23 |
-
# and Areas like
|
| 24 |
-
# "earth and planetary sciences; physics and astronomy"
|
| 25 |
-
#
|
| 26 |
-
# Both are ";"-delimited lists of names from a CLOSED vocabulary: 337
|
| 27 |
-
# categories and 27 areas. Treating either as free text is what produced
|
| 28 |
-
# the "crime" bug β "sciences" is a substring of 41% of all area strings,
|
| 29 |
-
# so an area word-match returned 13,154 journals, every one scoring
|
| 30 |
-
# identically, leaving h-index to sort the whole database.
|
| 31 |
-
|
| 32 |
-
_STOPWORDS = {"and", "of", "the", "in", "for", "or", "a", "an", "&"}
|
| 33 |
-
|
| 34 |
-
_QUARTILE_SUFFIX = re.compile(r"\s*\(q[1-4]\)\s*$")
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def _split_categories(raw: str) -> Set[str]:
|
| 38 |
-
""""a (q1); b (q2)" -> {"a", "b"}. Quartile suffix stripped."""
|
| 39 |
-
out = set()
|
| 40 |
-
for part in raw.split(";"):
|
| 41 |
-
name = _QUARTILE_SUFFIX.sub("", part.strip()).strip()
|
| 42 |
-
if name:
|
| 43 |
-
out.add(name)
|
| 44 |
-
return out
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _split_areas(raw: str) -> Set[str]:
|
| 48 |
-
return {p.strip() for p in raw.split(";") if p.strip()}
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def _tokens(s: str) -> Set[str]:
|
| 52 |
-
"""Significant whole words. Used for containment tests, never substrings."""
|
| 53 |
-
return {t for t in re.split(r"[^a-z0-9]+", (s or "").lower())
|
| 54 |
-
if t and t not in _STOPWORDS}
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
# ββ Vocabulary accessors βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
-
_vocab_cache: Optional[Dict[str, Dict[str, int]]] = None
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def subject_vocabulary() -> Dict[str, Dict[str, int]]:
|
| 62 |
-
"""
|
| 63 |
-
The real, closed vocabulary with journal counts.
|
| 64 |
-
|
| 65 |
-
{"categories": {"astronomy and astrophysics": 84, ...},
|
| 66 |
-
"areas": {"physics and astronomy": 3011, ...}}
|
| 67 |
-
|
| 68 |
-
Counts matter: they let the caller drop categories too small to be worth
|
| 69 |
-
offering, and they are the evidence that a name is real rather than
|
| 70 |
-
plausible.
|
| 71 |
-
"""
|
| 72 |
-
global _vocab_cache
|
| 73 |
-
if _vocab_cache is not None:
|
| 74 |
-
return _vocab_cache
|
| 75 |
-
cats: Dict[str, int] = {}
|
| 76 |
-
areas: Dict[str, int] = {}
|
| 77 |
-
for r in load_subject_index():
|
| 78 |
-
for c in r["cat_set"]:
|
| 79 |
-
cats[c] = cats.get(c, 0) + 1
|
| 80 |
-
for a in r["area_set"]:
|
| 81 |
-
areas[a] = areas.get(a, 0) + 1
|
| 82 |
-
_vocab_cache = {"categories": cats, "areas": areas}
|
| 83 |
-
return _vocab_cache
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def is_real_category(name: str) -> bool:
|
| 87 |
-
return bool(name) and name.strip().lower() in subject_vocabulary()["categories"]
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def is_real_area(name: str) -> bool:
|
| 91 |
-
return bool(name) and name.strip().lower() in subject_vocabulary()["areas"]
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
def suggest_categories(text: str, limit: int = 12) -> List[str]:
|
| 95 |
-
"""
|
| 96 |
-
Deterministic shortlist of REAL category names for a free-text subject.
|
| 97 |
-
Exact -> whole-word containment -> difflib. Never invents a name.
|
| 98 |
-
|
| 99 |
-
This exists so the LLM chooses from a closed list rather than generating
|
| 100 |
-
one: "Crime and Society" is a plausible SCImago category name and is not
|
| 101 |
-
a SCImago category at all.
|
| 102 |
-
"""
|
| 103 |
-
vocab = list(subject_vocabulary()["categories"].keys())
|
| 104 |
-
q = (text or "").strip().lower()
|
| 105 |
-
if not q:
|
| 106 |
-
return []
|
| 107 |
-
if q in vocab:
|
| 108 |
-
return [q]
|
| 109 |
-
|
| 110 |
-
qt = _tokens(q)
|
| 111 |
-
ranked: List[Tuple[int, str]] = []
|
| 112 |
-
for name in vocab:
|
| 113 |
-
nt = _tokens(name)
|
| 114 |
-
if not nt:
|
| 115 |
-
continue
|
| 116 |
-
if qt and qt <= nt:
|
| 117 |
-
ranked.append((3, name))
|
| 118 |
-
elif qt & nt:
|
| 119 |
-
ranked.append((2, name))
|
| 120 |
-
ranked.sort(key=lambda x: (-x[0], len(x[1])))
|
| 121 |
-
out = [n for _, n in ranked][:limit]
|
| 122 |
-
|
| 123 |
-
# difflib only as a typo repair, and only at a cutoff high enough to be
|
| 124 |
-
# about spelling rather than about vibes. At 0.72 it offered "immunology"
|
| 125 |
-
# for "criminology" (ratio 0.76) β lexically close, semantically absurd.
|
| 126 |
-
# 0.90 still catches "engineerng" -> "engineering" (0.95).
|
| 127 |
-
if len(out) < limit:
|
| 128 |
-
for n in difflib.get_close_matches(q, vocab, n=limit - len(out), cutoff=0.90):
|
| 129 |
-
if n not in out:
|
| 130 |
-
out.append(n)
|
| 131 |
-
return out[:limit]
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def load_scimago(csv_path: str = None) -> dict:
|
| 135 |
-
global _scimago_cache
|
| 136 |
-
if _scimago_cache:
|
| 137 |
-
return _scimago_cache
|
| 138 |
-
if csv_path is None:
|
| 139 |
-
csv_path = os.path.join(os.path.dirname(__file__), "scimagojr_2025.csv")
|
| 140 |
-
index = {}
|
| 141 |
-
try:
|
| 142 |
-
with open(csv_path, encoding="latin-1", newline="") as f:
|
| 143 |
-
reader = csv.DictReader(f, delimiter=",")
|
| 144 |
-
for row in reader:
|
| 145 |
-
issn_raw = row.get("Issn", "")
|
| 146 |
-
issn_parts = re.split(r"[,;\s]+", issn_raw)
|
| 147 |
-
entry = {
|
| 148 |
-
"title": row.get("Title", "").strip(),
|
| 149 |
-
"rank": row.get("Rank", "").strip(),
|
| 150 |
-
"quartile": row.get("SJR Best Quartile", "").strip(),
|
| 151 |
-
"h_index": row.get("H index", "").strip(),
|
| 152 |
-
"categories": row.get("Categories", "").strip(),
|
| 153 |
-
"areas": row.get("Areas", "").strip(),
|
| 154 |
-
"publisher": row.get("Publisher", "").strip(),
|
| 155 |
-
"open_access":row.get("Open Access", "No").strip(),
|
| 156 |
-
"country": row.get("Country", "").strip(),
|
| 157 |
-
"sourceid": row.get("Sourceid", "").strip(), # FIX: was missing
|
| 158 |
-
}
|
| 159 |
-
for part in issn_parts:
|
| 160 |
-
normed = norm_issn(part)
|
| 161 |
-
if normed:
|
| 162 |
-
index[normed] = entry
|
| 163 |
-
_scimago_cache = index
|
| 164 |
-
print(f"[SCImago] Loaded {len(index):,} ISSN entries.")
|
| 165 |
-
except Exception as e:
|
| 166 |
-
print(f"[SCImago] Failed: {e}")
|
| 167 |
-
_scimago_cache = {}
|
| 168 |
-
return _scimago_cache
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
def lookup_any(issns: list) -> Optional[dict]:
|
| 172 |
-
index = load_scimago()
|
| 173 |
-
for issn in issns:
|
| 174 |
-
normed = norm_issn(str(issn)) if issn else None
|
| 175 |
-
if normed and normed in index:
|
| 176 |
-
result = dict(index[normed])
|
| 177 |
-
result["matched_issn"] = normed
|
| 178 |
-
return result
|
| 179 |
-
return None
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
# ββ Subject search index (separate from ISSN index) ββββββββββββββββββββββ
|
| 183 |
-
_subject_cache: list = []
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
def load_subject_index(csv_path: str = None) -> list:
|
| 187 |
-
"""Load full journal list for subject browsing β separate from ISSN lookup."""
|
| 188 |
-
global _subject_cache
|
| 189 |
-
if _subject_cache:
|
| 190 |
-
return _subject_cache
|
| 191 |
-
if csv_path is None:
|
| 192 |
-
csv_path = os.path.join(os.path.dirname(__file__), "scimagojr_2025.csv")
|
| 193 |
-
rows = []
|
| 194 |
-
seen_titles = set()
|
| 195 |
-
try:
|
| 196 |
-
with open(csv_path, encoding="latin-1", newline="") as f:
|
| 197 |
-
reader = csv.DictReader(f, delimiter=",")
|
| 198 |
-
for row in reader:
|
| 199 |
-
title = row.get("Title", "").strip()
|
| 200 |
-
if not title or title in seen_titles:
|
| 201 |
-
continue
|
| 202 |
-
seen_titles.add(title)
|
| 203 |
-
issn_raw = row.get("Issn", "")
|
| 204 |
-
issn_parts = re.split(r"[,;\s]+", issn_raw)
|
| 205 |
-
issns = [norm_issn(p) for p in issn_parts if norm_issn(p)]
|
| 206 |
-
primary_issn = issns[0] if issns else None
|
| 207 |
-
try:
|
| 208 |
-
h = int(row.get("H index", "0").strip() or 0)
|
| 209 |
-
except Exception:
|
| 210 |
-
h = 0
|
| 211 |
-
cats_raw = row.get("Categories", "").strip().lower()
|
| 212 |
-
areas_raw = row.get("Areas", "").strip().lower()
|
| 213 |
-
rows.append({
|
| 214 |
-
"title": title,
|
| 215 |
-
"issn": primary_issn,
|
| 216 |
-
"quartile": row.get("SJR Best Quartile", "").strip(),
|
| 217 |
-
"h_index": h,
|
| 218 |
-
"categories": cats_raw,
|
| 219 |
-
"areas": areas_raw,
|
| 220 |
-
# Parsed once at load. Matching against these SETS instead
|
| 221 |
-
# of against the joined string is what stops "sciences"
|
| 222 |
-
# matching "earth and planetary sciences".
|
| 223 |
-
"cat_set": _split_categories(cats_raw),
|
| 224 |
-
"area_set": _split_areas(areas_raw),
|
| 225 |
-
"publisher": row.get("Publisher", "").strip(),
|
| 226 |
-
"open_access":row.get("Open Access", "No").strip(),
|
| 227 |
-
"country": row.get("Country", "").strip(),
|
| 228 |
-
"rank": row.get("Rank", "").strip(),
|
| 229 |
-
"sourceid": row.get("Sourceid", "").strip(),
|
| 230 |
-
})
|
| 231 |
-
_subject_cache = rows
|
| 232 |
-
print(f"[SCImago] Subject index loaded: {len(rows):,} unique journals.")
|
| 233 |
-
except Exception as e:
|
| 234 |
-
print(f"[SCImago] Subject index failed: {e}")
|
| 235 |
-
_subject_cache = []
|
| 236 |
-
return _subject_cache
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
def search_by_subject(category: str, area: str, top_n: int = 15,
|
| 240 |
-
with_level: bool = False):
|
| 241 |
-
"""
|
| 242 |
-
Journals for a SCImago category (and/or area), best match first.
|
| 243 |
-
|
| 244 |
-
Match tiers, highest first:
|
| 245 |
-
3 category name matches EXACTLY one of the journal's categories
|
| 246 |
-
2 every significant word of the query is a whole word in one of them
|
| 247 |
-
1 area name matches EXACTLY one of the journal's areas
|
| 248 |
-
|
| 249 |
-
All comparisons are against parsed name SETS. There is deliberately no
|
| 250 |
-
substring test anywhere: the previous implementation asked whether any
|
| 251 |
-
word of the area appeared anywhere in the joined area string, and
|
| 252 |
-
"sciences" is a substring of 13,154 journals' areas. Every one scored 1,
|
| 253 |
-
so the h-index tie-break silently returned the global top-30 β an
|
| 254 |
-
astrophysics list for a criminology query.
|
| 255 |
-
|
| 256 |
-
An area-only hit (tier 1) is a genuinely weaker answer than a category
|
| 257 |
-
hit and the caller must be able to say so. Pass with_level=True to get
|
| 258 |
-
(journals, level) where level is 3, 2, 1 or 0.
|
| 259 |
-
"""
|
| 260 |
-
rows = load_subject_index()
|
| 261 |
-
cat_lower = (category or "").strip().lower()
|
| 262 |
-
area_lower = (area or "").strip().lower()
|
| 263 |
-
cat_tokens = _tokens(cat_lower)
|
| 264 |
-
|
| 265 |
-
def score(r) -> int:
|
| 266 |
-
cs = r.get("cat_set") or set()
|
| 267 |
-
if cat_lower and cat_lower in cs:
|
| 268 |
-
return 3
|
| 269 |
-
if cat_tokens and any(cat_tokens <= _tokens(c) for c in cs):
|
| 270 |
-
return 2
|
| 271 |
-
if area_lower and area_lower in (r.get("area_set") or set()):
|
| 272 |
-
return 1
|
| 273 |
-
return 0
|
| 274 |
-
|
| 275 |
-
matched = []
|
| 276 |
-
for r in rows:
|
| 277 |
-
s = score(r)
|
| 278 |
-
if s:
|
| 279 |
-
matched.append((s, r))
|
| 280 |
-
|
| 281 |
-
if not matched:
|
| 282 |
-
return ([], 0) if with_level else []
|
| 283 |
-
|
| 284 |
-
matched.sort(key=lambda x: (-x[0], -x[1]["h_index"]))
|
| 285 |
-
best = matched[0][0]
|
| 286 |
-
out = [r for _, r in matched[:top_n]]
|
| 287 |
-
return (out, best) if with_level else out
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
def get_all_areas() -> list:
|
| 291 |
-
"""Return sorted unique area names for frontend hints."""
|
| 292 |
-
rows = load_subject_index()
|
| 293 |
-
areas = set()
|
| 294 |
-
for r in rows:
|
| 295 |
-
for a in r["areas"].split(";"):
|
| 296 |
-
a = a.strip().title()
|
| 297 |
-
if a:
|
| 298 |
-
areas.add(a)
|
| 299 |
return sorted(areas)
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SCImago CSV loader β builds in-memory ISSN β journal dict at startup.
|
| 3 |
+
Used PRIVATELY for quartile lookup only. Never exposed as branded data source in UI.
|
| 4 |
+
|
| 5 |
+
Changes from v1.0:
|
| 6 |
+
- _norm_issn removed β uses utils.norm_issn
|
| 7 |
+
- sourceid now included in ISSN index entries (was missing β broke verify_links)
|
| 8 |
+
"""
|
| 9 |
+
import csv
|
| 10 |
+
import re
|
| 11 |
+
import os
|
| 12 |
+
import difflib
|
| 13 |
+
from typing import Optional, List, Dict, Set, Tuple
|
| 14 |
+
from utils import norm_issn
|
| 15 |
+
|
| 16 |
+
_scimago_cache: dict = {}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ββ Closed-vocabulary parsing βββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
+
#
|
| 21 |
+
# SCImago's Categories column looks like
|
| 22 |
+
# "astronomy and astrophysics (q1); space and planetary science (q1)"
|
| 23 |
+
# and Areas like
|
| 24 |
+
# "earth and planetary sciences; physics and astronomy"
|
| 25 |
+
#
|
| 26 |
+
# Both are ";"-delimited lists of names from a CLOSED vocabulary: 337
|
| 27 |
+
# categories and 27 areas. Treating either as free text is what produced
|
| 28 |
+
# the "crime" bug β "sciences" is a substring of 41% of all area strings,
|
| 29 |
+
# so an area word-match returned 13,154 journals, every one scoring
|
| 30 |
+
# identically, leaving h-index to sort the whole database.
|
| 31 |
+
|
| 32 |
+
_STOPWORDS = {"and", "of", "the", "in", "for", "or", "a", "an", "&"}
|
| 33 |
+
|
| 34 |
+
_QUARTILE_SUFFIX = re.compile(r"\s*\(q[1-4]\)\s*$")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _split_categories(raw: str) -> Set[str]:
|
| 38 |
+
""""a (q1); b (q2)" -> {"a", "b"}. Quartile suffix stripped."""
|
| 39 |
+
out = set()
|
| 40 |
+
for part in raw.split(";"):
|
| 41 |
+
name = _QUARTILE_SUFFIX.sub("", part.strip()).strip()
|
| 42 |
+
if name:
|
| 43 |
+
out.add(name)
|
| 44 |
+
return out
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _split_areas(raw: str) -> Set[str]:
|
| 48 |
+
return {p.strip() for p in raw.split(";") if p.strip()}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _tokens(s: str) -> Set[str]:
|
| 52 |
+
"""Significant whole words. Used for containment tests, never substrings."""
|
| 53 |
+
return {t for t in re.split(r"[^a-z0-9]+", (s or "").lower())
|
| 54 |
+
if t and t not in _STOPWORDS}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ββ Vocabulary accessors βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
_vocab_cache: Optional[Dict[str, Dict[str, int]]] = None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def subject_vocabulary() -> Dict[str, Dict[str, int]]:
|
| 62 |
+
"""
|
| 63 |
+
The real, closed vocabulary with journal counts.
|
| 64 |
+
|
| 65 |
+
{"categories": {"astronomy and astrophysics": 84, ...},
|
| 66 |
+
"areas": {"physics and astronomy": 3011, ...}}
|
| 67 |
+
|
| 68 |
+
Counts matter: they let the caller drop categories too small to be worth
|
| 69 |
+
offering, and they are the evidence that a name is real rather than
|
| 70 |
+
plausible.
|
| 71 |
+
"""
|
| 72 |
+
global _vocab_cache
|
| 73 |
+
if _vocab_cache is not None:
|
| 74 |
+
return _vocab_cache
|
| 75 |
+
cats: Dict[str, int] = {}
|
| 76 |
+
areas: Dict[str, int] = {}
|
| 77 |
+
for r in load_subject_index():
|
| 78 |
+
for c in r["cat_set"]:
|
| 79 |
+
cats[c] = cats.get(c, 0) + 1
|
| 80 |
+
for a in r["area_set"]:
|
| 81 |
+
areas[a] = areas.get(a, 0) + 1
|
| 82 |
+
_vocab_cache = {"categories": cats, "areas": areas}
|
| 83 |
+
return _vocab_cache
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def is_real_category(name: str) -> bool:
|
| 87 |
+
return bool(name) and name.strip().lower() in subject_vocabulary()["categories"]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def is_real_area(name: str) -> bool:
|
| 91 |
+
return bool(name) and name.strip().lower() in subject_vocabulary()["areas"]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def suggest_categories(text: str, limit: int = 12) -> List[str]:
|
| 95 |
+
"""
|
| 96 |
+
Deterministic shortlist of REAL category names for a free-text subject.
|
| 97 |
+
Exact -> whole-word containment -> difflib. Never invents a name.
|
| 98 |
+
|
| 99 |
+
This exists so the LLM chooses from a closed list rather than generating
|
| 100 |
+
one: "Crime and Society" is a plausible SCImago category name and is not
|
| 101 |
+
a SCImago category at all.
|
| 102 |
+
"""
|
| 103 |
+
vocab = list(subject_vocabulary()["categories"].keys())
|
| 104 |
+
q = (text or "").strip().lower()
|
| 105 |
+
if not q:
|
| 106 |
+
return []
|
| 107 |
+
if q in vocab:
|
| 108 |
+
return [q]
|
| 109 |
+
|
| 110 |
+
qt = _tokens(q)
|
| 111 |
+
ranked: List[Tuple[int, str]] = []
|
| 112 |
+
for name in vocab:
|
| 113 |
+
nt = _tokens(name)
|
| 114 |
+
if not nt:
|
| 115 |
+
continue
|
| 116 |
+
if qt and qt <= nt:
|
| 117 |
+
ranked.append((3, name))
|
| 118 |
+
elif qt & nt:
|
| 119 |
+
ranked.append((2, name))
|
| 120 |
+
ranked.sort(key=lambda x: (-x[0], len(x[1])))
|
| 121 |
+
out = [n for _, n in ranked][:limit]
|
| 122 |
+
|
| 123 |
+
# difflib only as a typo repair, and only at a cutoff high enough to be
|
| 124 |
+
# about spelling rather than about vibes. At 0.72 it offered "immunology"
|
| 125 |
+
# for "criminology" (ratio 0.76) β lexically close, semantically absurd.
|
| 126 |
+
# 0.90 still catches "engineerng" -> "engineering" (0.95).
|
| 127 |
+
if len(out) < limit:
|
| 128 |
+
for n in difflib.get_close_matches(q, vocab, n=limit - len(out), cutoff=0.90):
|
| 129 |
+
if n not in out:
|
| 130 |
+
out.append(n)
|
| 131 |
+
return out[:limit]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def load_scimago(csv_path: str = None) -> dict:
|
| 135 |
+
global _scimago_cache
|
| 136 |
+
if _scimago_cache:
|
| 137 |
+
return _scimago_cache
|
| 138 |
+
if csv_path is None:
|
| 139 |
+
csv_path = os.path.join(os.path.dirname(__file__), "scimagojr_2025.csv")
|
| 140 |
+
index = {}
|
| 141 |
+
try:
|
| 142 |
+
with open(csv_path, encoding="latin-1", newline="") as f:
|
| 143 |
+
reader = csv.DictReader(f, delimiter=",")
|
| 144 |
+
for row in reader:
|
| 145 |
+
issn_raw = row.get("Issn", "")
|
| 146 |
+
issn_parts = re.split(r"[,;\s]+", issn_raw)
|
| 147 |
+
entry = {
|
| 148 |
+
"title": row.get("Title", "").strip(),
|
| 149 |
+
"rank": row.get("Rank", "").strip(),
|
| 150 |
+
"quartile": row.get("SJR Best Quartile", "").strip(),
|
| 151 |
+
"h_index": row.get("H index", "").strip(),
|
| 152 |
+
"categories": row.get("Categories", "").strip(),
|
| 153 |
+
"areas": row.get("Areas", "").strip(),
|
| 154 |
+
"publisher": row.get("Publisher", "").strip(),
|
| 155 |
+
"open_access":row.get("Open Access", "No").strip(),
|
| 156 |
+
"country": row.get("Country", "").strip(),
|
| 157 |
+
"sourceid": row.get("Sourceid", "").strip(), # FIX: was missing
|
| 158 |
+
}
|
| 159 |
+
for part in issn_parts:
|
| 160 |
+
normed = norm_issn(part)
|
| 161 |
+
if normed:
|
| 162 |
+
index[normed] = entry
|
| 163 |
+
_scimago_cache = index
|
| 164 |
+
print(f"[SCImago] Loaded {len(index):,} ISSN entries.")
|
| 165 |
+
except Exception as e:
|
| 166 |
+
print(f"[SCImago] Failed: {e}")
|
| 167 |
+
_scimago_cache = {}
|
| 168 |
+
return _scimago_cache
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def lookup_any(issns: list) -> Optional[dict]:
|
| 172 |
+
index = load_scimago()
|
| 173 |
+
for issn in issns:
|
| 174 |
+
normed = norm_issn(str(issn)) if issn else None
|
| 175 |
+
if normed and normed in index:
|
| 176 |
+
result = dict(index[normed])
|
| 177 |
+
result["matched_issn"] = normed
|
| 178 |
+
return result
|
| 179 |
+
return None
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ββ Subject search index (separate from ISSN index) ββββββββββββββββββββββ
|
| 183 |
+
_subject_cache: list = []
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def load_subject_index(csv_path: str = None) -> list:
|
| 187 |
+
"""Load full journal list for subject browsing β separate from ISSN lookup."""
|
| 188 |
+
global _subject_cache
|
| 189 |
+
if _subject_cache:
|
| 190 |
+
return _subject_cache
|
| 191 |
+
if csv_path is None:
|
| 192 |
+
csv_path = os.path.join(os.path.dirname(__file__), "scimagojr_2025.csv")
|
| 193 |
+
rows = []
|
| 194 |
+
seen_titles = set()
|
| 195 |
+
try:
|
| 196 |
+
with open(csv_path, encoding="latin-1", newline="") as f:
|
| 197 |
+
reader = csv.DictReader(f, delimiter=",")
|
| 198 |
+
for row in reader:
|
| 199 |
+
title = row.get("Title", "").strip()
|
| 200 |
+
if not title or title in seen_titles:
|
| 201 |
+
continue
|
| 202 |
+
seen_titles.add(title)
|
| 203 |
+
issn_raw = row.get("Issn", "")
|
| 204 |
+
issn_parts = re.split(r"[,;\s]+", issn_raw)
|
| 205 |
+
issns = [norm_issn(p) for p in issn_parts if norm_issn(p)]
|
| 206 |
+
primary_issn = issns[0] if issns else None
|
| 207 |
+
try:
|
| 208 |
+
h = int(row.get("H index", "0").strip() or 0)
|
| 209 |
+
except Exception:
|
| 210 |
+
h = 0
|
| 211 |
+
cats_raw = row.get("Categories", "").strip().lower()
|
| 212 |
+
areas_raw = row.get("Areas", "").strip().lower()
|
| 213 |
+
rows.append({
|
| 214 |
+
"title": title,
|
| 215 |
+
"issn": primary_issn,
|
| 216 |
+
"quartile": row.get("SJR Best Quartile", "").strip(),
|
| 217 |
+
"h_index": h,
|
| 218 |
+
"categories": cats_raw,
|
| 219 |
+
"areas": areas_raw,
|
| 220 |
+
# Parsed once at load. Matching against these SETS instead
|
| 221 |
+
# of against the joined string is what stops "sciences"
|
| 222 |
+
# matching "earth and planetary sciences".
|
| 223 |
+
"cat_set": _split_categories(cats_raw),
|
| 224 |
+
"area_set": _split_areas(areas_raw),
|
| 225 |
+
"publisher": row.get("Publisher", "").strip(),
|
| 226 |
+
"open_access":row.get("Open Access", "No").strip(),
|
| 227 |
+
"country": row.get("Country", "").strip(),
|
| 228 |
+
"rank": row.get("Rank", "").strip(),
|
| 229 |
+
"sourceid": row.get("Sourceid", "").strip(),
|
| 230 |
+
})
|
| 231 |
+
_subject_cache = rows
|
| 232 |
+
print(f"[SCImago] Subject index loaded: {len(rows):,} unique journals.")
|
| 233 |
+
except Exception as e:
|
| 234 |
+
print(f"[SCImago] Subject index failed: {e}")
|
| 235 |
+
_subject_cache = []
|
| 236 |
+
return _subject_cache
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def search_by_subject(category: str, area: str, top_n: int = 15,
|
| 240 |
+
with_level: bool = False):
|
| 241 |
+
"""
|
| 242 |
+
Journals for a SCImago category (and/or area), best match first.
|
| 243 |
+
|
| 244 |
+
Match tiers, highest first:
|
| 245 |
+
3 category name matches EXACTLY one of the journal's categories
|
| 246 |
+
2 every significant word of the query is a whole word in one of them
|
| 247 |
+
1 area name matches EXACTLY one of the journal's areas
|
| 248 |
+
|
| 249 |
+
All comparisons are against parsed name SETS. There is deliberately no
|
| 250 |
+
substring test anywhere: the previous implementation asked whether any
|
| 251 |
+
word of the area appeared anywhere in the joined area string, and
|
| 252 |
+
"sciences" is a substring of 13,154 journals' areas. Every one scored 1,
|
| 253 |
+
so the h-index tie-break silently returned the global top-30 β an
|
| 254 |
+
astrophysics list for a criminology query.
|
| 255 |
+
|
| 256 |
+
An area-only hit (tier 1) is a genuinely weaker answer than a category
|
| 257 |
+
hit and the caller must be able to say so. Pass with_level=True to get
|
| 258 |
+
(journals, level) where level is 3, 2, 1 or 0.
|
| 259 |
+
"""
|
| 260 |
+
rows = load_subject_index()
|
| 261 |
+
cat_lower = (category or "").strip().lower()
|
| 262 |
+
area_lower = (area or "").strip().lower()
|
| 263 |
+
cat_tokens = _tokens(cat_lower)
|
| 264 |
+
|
| 265 |
+
def score(r) -> int:
|
| 266 |
+
cs = r.get("cat_set") or set()
|
| 267 |
+
if cat_lower and cat_lower in cs:
|
| 268 |
+
return 3
|
| 269 |
+
if cat_tokens and any(cat_tokens <= _tokens(c) for c in cs):
|
| 270 |
+
return 2
|
| 271 |
+
if area_lower and area_lower in (r.get("area_set") or set()):
|
| 272 |
+
return 1
|
| 273 |
+
return 0
|
| 274 |
+
|
| 275 |
+
matched = []
|
| 276 |
+
for r in rows:
|
| 277 |
+
s = score(r)
|
| 278 |
+
if s:
|
| 279 |
+
matched.append((s, r))
|
| 280 |
+
|
| 281 |
+
if not matched:
|
| 282 |
+
return ([], 0) if with_level else []
|
| 283 |
+
|
| 284 |
+
matched.sort(key=lambda x: (-x[0], -x[1]["h_index"]))
|
| 285 |
+
best = matched[0][0]
|
| 286 |
+
out = [r for _, r in matched[:top_n]]
|
| 287 |
+
return (out, best) if with_level else out
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def get_all_areas() -> list:
|
| 291 |
+
"""Return sorted unique area names for frontend hints."""
|
| 292 |
+
rows = load_subject_index()
|
| 293 |
+
areas = set()
|
| 294 |
+
for r in rows:
|
| 295 |
+
for a in r["areas"].split(";"):
|
| 296 |
+
a = a.strip().title()
|
| 297 |
+
if a:
|
| 298 |
+
areas.add(a)
|
| 299 |
return sorted(areas)
|
style.css
CHANGED
|
@@ -1,28 +1,28 @@
|
|
| 1 |
-
body {
|
| 2 |
-
padding: 2rem;
|
| 3 |
-
font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
|
| 4 |
-
}
|
| 5 |
-
|
| 6 |
-
h1 {
|
| 7 |
-
font-size: 16px;
|
| 8 |
-
margin-top: 0;
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
p {
|
| 12 |
-
color: rgb(107, 114, 128);
|
| 13 |
-
font-size: 15px;
|
| 14 |
-
margin-bottom: 10px;
|
| 15 |
-
margin-top: 5px;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
.card {
|
| 19 |
-
max-width: 620px;
|
| 20 |
-
margin: 0 auto;
|
| 21 |
-
padding: 16px;
|
| 22 |
-
border: 1px solid lightgray;
|
| 23 |
-
border-radius: 16px;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
.card p:last-child {
|
| 27 |
-
margin-bottom: 0;
|
| 28 |
-
}
|
|
|
|
| 1 |
+
body {
|
| 2 |
+
padding: 2rem;
|
| 3 |
+
font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
h1 {
|
| 7 |
+
font-size: 16px;
|
| 8 |
+
margin-top: 0;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
p {
|
| 12 |
+
color: rgb(107, 114, 128);
|
| 13 |
+
font-size: 15px;
|
| 14 |
+
margin-bottom: 10px;
|
| 15 |
+
margin-top: 5px;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
.card {
|
| 19 |
+
max-width: 620px;
|
| 20 |
+
margin: 0 auto;
|
| 21 |
+
padding: 16px;
|
| 22 |
+
border: 1px solid lightgray;
|
| 23 |
+
border-radius: 16px;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.card p:last-child {
|
| 27 |
+
margin-bottom: 0;
|
| 28 |
+
}
|
train_asjc.py
CHANGED
|
@@ -1,329 +1,329 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Train the abstract -> ASJC multi-label classifier.
|
| 3 |
-
|
| 4 |
-
# interim model, no network needed β trains on journal titles in the index
|
| 5 |
-
python train_asjc.py --corpus titles --db journals.db --out asjc_model.joblib
|
| 6 |
-
|
| 7 |
-
# full model, after harvest_openalex.py has produced labelled abstracts
|
| 8 |
-
python train_asjc.py --corpus abstracts --train-jsonl asjc_train.jsonl \
|
| 9 |
-
--db journals.db --out asjc_model.joblib
|
| 10 |
-
|
| 11 |
-
Why TF-IDF + linear and not a transformer
|
| 12 |
-
-----------------------------------------
|
| 13 |
-
The task is topical multi-label text classification over 334 labels with a large
|
| 14 |
-
vocabulary signal β exactly where a linear model over character and word n-grams
|
| 15 |
-
is hard to beat per unit of cost. It trains in seconds on CPU, the artifact is
|
| 16 |
-
tens of MB, and inference is sub-millisecond with no GPU and no model download at
|
| 17 |
-
Space startup. A fine-tuned transformer might add a few points of F1 for ~400MB
|
| 18 |
-
and a cold-start penalty on every Space restart. Take the linear model; spend the
|
| 19 |
-
complexity budget on the LLM fallback for the cases it actually gets wrong.
|
| 20 |
-
|
| 21 |
-
Two corpora, deliberately
|
| 22 |
-
-------------------------
|
| 23 |
-
`titles` trains on journal titles (48,888 labelled examples, already in the
|
| 24 |
-
index, zero network). Ships immediately. But it trains on titles and
|
| 25 |
-
is applied to abstracts, and that domain gap is real β treat its
|
| 26 |
-
numbers as an upper bound on what it will do in production.
|
| 27 |
-
|
| 28 |
-
`abstracts` trains on article abstracts labelled with their journal's ASJC codes
|
| 29 |
-
(weak supervision). Far better matched to inference, and the only
|
| 30 |
-
version whose held-out score means what it appears to mean.
|
| 31 |
-
|
| 32 |
-
Label noise in the `abstracts` corpus
|
| 33 |
-
-------------------------------------
|
| 34 |
-
Giving every article its journal's codes is weak supervision: a physics paper in
|
| 35 |
-
a multidisciplinary journal inherits 'Medicine' too. `--max-labels` drops journals
|
| 36 |
-
carrying more than N ASJC codes from training, which removes the worst offenders
|
| 37 |
-
(megajournals and general-science titles) at the cost of some coverage. Their
|
| 38 |
-
articles are still *classifiable* at inference β they are just poor teachers.
|
| 39 |
-
"""
|
| 40 |
-
from __future__ import annotations
|
| 41 |
-
|
| 42 |
-
import argparse
|
| 43 |
-
import json
|
| 44 |
-
import re
|
| 45 |
-
import sqlite3
|
| 46 |
-
import sys
|
| 47 |
-
from pathlib import Path
|
| 48 |
-
|
| 49 |
-
import numpy as np
|
| 50 |
-
from scipy import sparse
|
| 51 |
-
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 52 |
-
from sklearn.linear_model import SGDClassifier
|
| 53 |
-
from sklearn.model_selection import train_test_split
|
| 54 |
-
from sklearn.multiclass import OneVsRestClassifier
|
| 55 |
-
from sklearn.preprocessing import MultiLabelBinarizer
|
| 56 |
-
|
| 57 |
-
import joblib
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
# ---------------------------------------------------------------------------
|
| 61 |
-
# Corpora
|
| 62 |
-
# ---------------------------------------------------------------------------
|
| 63 |
-
def load_titles(db: str, max_labels: int) -> tuple[list[str], list[list[str]]]:
|
| 64 |
-
"""Journal title + subject names -> its ASJC codes."""
|
| 65 |
-
con = sqlite3.connect(db)
|
| 66 |
-
con.row_factory = sqlite3.Row
|
| 67 |
-
rows = con.execute(
|
| 68 |
-
"""SELECT j.scopus_id, j.title, j.publisher_group,
|
| 69 |
-
group_concat(ja.asjc) codes, count(*) n
|
| 70 |
-
FROM journals j JOIN journal_asjc ja ON ja.scopus_id = j.scopus_id
|
| 71 |
-
WHERE j.title IS NOT NULL
|
| 72 |
-
GROUP BY j.scopus_id"""
|
| 73 |
-
).fetchall()
|
| 74 |
-
X, y = [], []
|
| 75 |
-
for r in rows:
|
| 76 |
-
if r["n"] > max_labels:
|
| 77 |
-
continue
|
| 78 |
-
X.append(r["title"])
|
| 79 |
-
y.append(r["codes"].split(","))
|
| 80 |
-
con.close()
|
| 81 |
-
return X, y
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def load_abstracts(path: str, db: str, max_labels: int) -> tuple[list[str], list[list[str]]]:
|
| 85 |
-
"""
|
| 86 |
-
JSONL from harvest_openalex.py: {"text": ..., "scopus_id": ...}.
|
| 87 |
-
Labels come from the index, so the harvest never has to carry them.
|
| 88 |
-
"""
|
| 89 |
-
con = sqlite3.connect(db)
|
| 90 |
-
codes: dict[str, list[str]] = {}
|
| 91 |
-
for sid, c in con.execute(
|
| 92 |
-
"SELECT scopus_id, group_concat(asjc) FROM journal_asjc GROUP BY scopus_id"
|
| 93 |
-
):
|
| 94 |
-
codes[sid] = c.split(",")
|
| 95 |
-
con.close()
|
| 96 |
-
|
| 97 |
-
X, y = [], []
|
| 98 |
-
with open(path, encoding="utf-8") as f:
|
| 99 |
-
for line in f:
|
| 100 |
-
rec = json.loads(line)
|
| 101 |
-
c = codes.get(rec.get("scopus_id"))
|
| 102 |
-
if not c or len(c) > max_labels:
|
| 103 |
-
continue
|
| 104 |
-
t = (rec.get("text") or "").strip()
|
| 105 |
-
if len(t) < 120: # a bare title is not an abstract
|
| 106 |
-
continue
|
| 107 |
-
X.append(t)
|
| 108 |
-
y.append(c)
|
| 109 |
-
return X, y
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
# ---------------------------------------------------------------------------
|
| 113 |
-
# Evaluation
|
| 114 |
-
# ---------------------------------------------------------------------------
|
| 115 |
-
def evaluate(clf, Xv, Y_true, classes: np.ndarray, parent_of: dict[str, str]) -> dict:
|
| 116 |
-
"""
|
| 117 |
-
Ranking metrics, not threshold metrics.
|
| 118 |
-
|
| 119 |
-
The classifier feeds a *filter*, and the filter takes the top few codes β so
|
| 120 |
-
what matters is whether a correct code is ranked highly, not whether some
|
| 121 |
-
probability cleared 0.5. Reporting only micro-F1 at a fixed threshold would
|
| 122 |
-
flatter a model that is well-calibrated but badly ordered, and vice versa.
|
| 123 |
-
|
| 124 |
-
Parent accuracy (2-digit ASJC) is reported alongside because confusing
|
| 125 |
-
'Information Systems' with 'Computer Science Applications' barely harms the
|
| 126 |
-
candidate set, while confusing Computer Science with Dentistry destroys it.
|
| 127 |
-
"""
|
| 128 |
-
P = clf.predict_proba(Xv)
|
| 129 |
-
order = np.argsort(-P, axis=1)
|
| 130 |
-
res: dict[str, float] = {}
|
| 131 |
-
|
| 132 |
-
for k in (1, 3, 5):
|
| 133 |
-
topk = order[:, :k]
|
| 134 |
-
hit = sum(
|
| 135 |
-
bool(set(classes[topk[i]]) & set(Y_true[i])) for i in range(len(Y_true))
|
| 136 |
-
)
|
| 137 |
-
res[f"top{k}_hit"] = hit / len(Y_true)
|
| 138 |
-
|
| 139 |
-
# Recall of the full true label set within the top 5 predictions.
|
| 140 |
-
top5 = order[:, :5]
|
| 141 |
-
res["top5_label_recall"] = float(np.mean([
|
| 142 |
-
len(set(classes[top5[i]]) & set(Y_true[i])) / len(Y_true[i])
|
| 143 |
-
for i in range(len(Y_true))
|
| 144 |
-
]))
|
| 145 |
-
|
| 146 |
-
top1_parent = [parent_of.get(classes[order[i, 0]]) for i in range(len(Y_true))]
|
| 147 |
-
res["top1_parent_hit"] = float(np.mean([
|
| 148 |
-
top1_parent[i] in {parent_of.get(c) for c in Y_true[i]}
|
| 149 |
-
for i in range(len(Y_true))
|
| 150 |
-
]))
|
| 151 |
-
return res
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
def main() -> None:
|
| 155 |
-
ap = argparse.ArgumentParser()
|
| 156 |
-
ap.add_argument("--corpus", choices=["titles", "abstracts"], default="titles")
|
| 157 |
-
ap.add_argument("--db", default="journals.db")
|
| 158 |
-
ap.add_argument("--train-jsonl", default="asjc_train.jsonl")
|
| 159 |
-
ap.add_argument("--out", default="asjc_model.joblib")
|
| 160 |
-
ap.add_argument("--max-labels", type=int, default=6,
|
| 161 |
-
help="drop journals carrying more ASJC codes than this from training")
|
| 162 |
-
ap.add_argument("--min-per-label", type=int, default=8,
|
| 163 |
-
help="drop labels with fewer training examples than this")
|
| 164 |
-
ap.add_argument("--eval-jsonl", default="asjc_eval.jsonl")
|
| 165 |
-
ap.add_argument("--max-features", type=int, default=120_000)
|
| 166 |
-
ap.add_argument("--n-jobs", type=int, default=2,
|
| 167 |
-
help="parallel per-label fits. 334 estimators over a large sparse "
|
| 168 |
-
"matrix can exhaust memory at -1; 4 is safe on a 16GB Space.")
|
| 169 |
-
args = ap.parse_args()
|
| 170 |
-
|
| 171 |
-
if args.corpus == "titles":
|
| 172 |
-
X, y = load_titles(args.db, args.max_labels)
|
| 173 |
-
else:
|
| 174 |
-
if not Path(args.train_jsonl).exists():
|
| 175 |
-
sys.exit(f"{args.train_jsonl} not found β run harvest_openalex.py first")
|
| 176 |
-
X, y = load_abstracts(args.train_jsonl, args.db, args.max_labels)
|
| 177 |
-
|
| 178 |
-
print(f"corpus={args.corpus} examples={len(X)} "
|
| 179 |
-
f"mean labels/example={np.mean([len(c) for c in y]):.2f}")
|
| 180 |
-
|
| 181 |
-
# Drop labels too rare to learn. They stay resolvable through the LLM
|
| 182 |
-
# fallback, which does not need training examples.
|
| 183 |
-
from collections import Counter
|
| 184 |
-
freq = Counter(c for cs in y for c in cs)
|
| 185 |
-
keep = {c for c, n in freq.items() if n >= args.min_per_label}
|
| 186 |
-
y = [[c for c in cs if c in keep] for cs in y]
|
| 187 |
-
mask = [i for i, cs in enumerate(y) if cs]
|
| 188 |
-
X, y = [X[i] for i in mask], [y[i] for i in mask]
|
| 189 |
-
print(f"labels kept: {len(keep)} of {len(freq)} examples after filtering: {len(X)}")
|
| 190 |
-
|
| 191 |
-
mlb = MultiLabelBinarizer()
|
| 192 |
-
Y = mlb.fit_transform(y)
|
| 193 |
-
|
| 194 |
-
Xtr, Xte, Ytr, Yte, ytr_raw, yte_raw = train_test_split(
|
| 195 |
-
X, Y, y, test_size=0.2, random_state=42
|
| 196 |
-
)
|
| 197 |
-
|
| 198 |
-
# Word n-grams carry the topical signal; character n-grams cover
|
| 199 |
-
# morphology, non-English titles and the domain shift between titles and
|
| 200 |
-
# abstracts. Union of both, sublinear tf to damp long abstracts.
|
| 201 |
-
word = TfidfVectorizer(
|
| 202 |
-
sublinear_tf=True, strip_accents="unicode", lowercase=True,
|
| 203 |
-
ngram_range=(1, 2), min_df=2, max_features=args.max_features,
|
| 204 |
-
stop_words="english", dtype=np.float32,
|
| 205 |
-
)
|
| 206 |
-
char = TfidfVectorizer(
|
| 207 |
-
sublinear_tf=True, strip_accents="unicode", lowercase=True,
|
| 208 |
-
analyzer="char_wb", ngram_range=(3, 5), min_df=3,
|
| 209 |
-
max_features=args.max_features // 3, dtype=np.float32,
|
| 210 |
-
)
|
| 211 |
-
Xtr_v = sparse.hstack([word.fit_transform(Xtr), char.fit_transform(Xtr)]).tocsr()
|
| 212 |
-
Xte_v = sparse.hstack([word.transform(Xte), char.transform(Xte)]).tocsr()
|
| 213 |
-
print(f"features: {Xtr_v.shape[1]}")
|
| 214 |
-
|
| 215 |
-
# SGD with modified_huber rather than liblinear logistic regression.
|
| 216 |
-
# 334 one-vs-rest fits over ~450k sparse features takes liblinear many
|
| 217 |
-
# minutes and it trips joblib's read-only memmap under parallel fan-out;
|
| 218 |
-
# SGD fits the same problem in seconds, scales to the ~1M-abstract corpus
|
| 219 |
-
# the harvest will produce, and modified_huber still exposes predict_proba,
|
| 220 |
-
# which the LLM fallback needs to decide when to escalate.
|
| 221 |
-
# log_loss, not modified_huber. modified_huber's predict_proba clips the
|
| 222 |
-
# margin and normalises, so it returns a saturated 1.0/0.0 with nothing in
|
| 223 |
-
# between β fine for ranking, useless for deciding when to escalate to the
|
| 224 |
-
# LLM. log_loss gives genuine logistic probabilities that a threshold can
|
| 225 |
-
# act on.
|
| 226 |
-
#
|
| 227 |
-
# class_weight is left unbalanced on purpose. Balancing inflates scores for
|
| 228 |
-
# rare labels, which is right for per-label F1 and wrong here: it pushed
|
| 229 |
-
# 'Rehabilitation' to the top of a cardiology abstract. The output feeds a
|
| 230 |
-
# ranked filter, so ordering matters more than per-label parity.
|
| 231 |
-
clf = OneVsRestClassifier(
|
| 232 |
-
SGDClassifier(
|
| 233 |
-
loss="log_loss", alpha=1e-6, max_iter=30, tol=1e-4,
|
| 234 |
-
random_state=42,
|
| 235 |
-
),
|
| 236 |
-
n_jobs=args.n_jobs,
|
| 237 |
-
)
|
| 238 |
-
with joblib.parallel_config(backend="loky", max_nbytes=None):
|
| 239 |
-
clf.fit(Xtr_v, Ytr)
|
| 240 |
-
|
| 241 |
-
con = sqlite3.connect(args.db)
|
| 242 |
-
parent_of = {c: p for c, p in con.execute("SELECT code, parent_code FROM asjc")}
|
| 243 |
-
names = {c: (n or pn) for c, n, pn in
|
| 244 |
-
con.execute("SELECT code, name, parent_name FROM asjc")}
|
| 245 |
-
con.close()
|
| 246 |
-
|
| 247 |
-
res = evaluate(clf, Xte_v, yte_raw, mlb.classes_, parent_of)
|
| 248 |
-
print(f"\nheld-out performance (same distribution as training: {args.corpus})")
|
| 249 |
-
for k, v in res.items():
|
| 250 |
-
print(f" {k:<20} {v:.3f}")
|
| 251 |
-
|
| 252 |
-
# The number that actually matters. When training on `titles`, the held-out
|
| 253 |
-
# score above is measured on 5-word journal titles while production input is
|
| 254 |
-
# a 200-word abstract β a different and much easier problem. This second
|
| 255 |
-
# evaluation runs on hand-labelled abstracts across 20 fields, which is the
|
| 256 |
-
# distribution the classifier is really deployed against.
|
| 257 |
-
if Path(args.eval_jsonl).exists():
|
| 258 |
-
ev = [json.loads(l) for l in open(args.eval_jsonl, encoding="utf-8")]
|
| 259 |
-
Xe = sparse.hstack([word.transform([e["text"] for e in ev]),
|
| 260 |
-
char.transform([e["text"] for e in ev])]).tocsr()
|
| 261 |
-
Pe = clf.predict_proba(Xe)
|
| 262 |
-
order = np.argsort(-Pe, axis=1)
|
| 263 |
-
# Two standards, reported separately and deliberately.
|
| 264 |
-
#
|
| 265 |
-
# LENIENT counts a hit when *any* acceptable code appears. It overstates
|
| 266 |
-
# usefulness: for a seismic-engineering abstract, 'Engineering
|
| 267 |
-
# (miscellaneous)' is in `accept` and scores a hit, but filtering on it
|
| 268 |
-
# returns nonlinear-analysis maths journals, while the preferred code
|
| 269 |
-
# 2205 returns concrete-structures journals. Same "hit", different
|
| 270 |
-
# recommendation quality.
|
| 271 |
-
#
|
| 272 |
-
# STRICT requires the single best code β accept[0], the one a subject
|
| 273 |
-
# librarian would choose. This is the number to watch when retraining on
|
| 274 |
-
# the abstract corpus, because it is the one that tracks whether the
|
| 275 |
-
# recommendations are actually good.
|
| 276 |
-
n = len(ev)
|
| 277 |
-
hits1 = hits3 = parent1 = 0
|
| 278 |
-
s5 = s3 = 0
|
| 279 |
-
print(f"\nabstract eval ({n} hand-labelled abstracts)")
|
| 280 |
-
for i, e in enumerate(ev):
|
| 281 |
-
top5 = [mlb.classes_[j] for j in order[i, :5]]
|
| 282 |
-
top3, top1 = top5[:3], top5[0]
|
| 283 |
-
pref = e["accept"][0]
|
| 284 |
-
ok1 = top1 in e["accept"]
|
| 285 |
-
ok3 = bool(set(top3) & set(e["accept"]))
|
| 286 |
-
okp = parent_of.get(top1) in {parent_of.get(c) for c in e["accept"]}
|
| 287 |
-
hits1 += ok1; hits3 += ok3; parent1 += okp
|
| 288 |
-
s5 += pref in top5
|
| 289 |
-
s3 += pref in top3
|
| 290 |
-
flag = "OK " if pref in top5 else ("weak" if ok3 else "MISS")
|
| 291 |
-
print(f" {flag} {e['field'][:28]:<28} pref {pref} "
|
| 292 |
-
f"{'in top5' if pref in top5 else 'ABSENT':<8} -> "
|
| 293 |
-
f"{', '.join(f'{c}' for c in top3)}")
|
| 294 |
-
print(f" lenient: top1 {hits1/n:.2f} top3 {hits3/n:.2f} parent {parent1/n:.2f}")
|
| 295 |
-
print(f" STRICT : preferred-in-top3 {s3/n:.2f} preferred-in-top5 {s5/n:.2f}")
|
| 296 |
-
print(" strict preferred-in-top5 is the number that gates shipping β it "
|
| 297 |
-
"bounds what the LLM stage can possibly pick.")
|
| 298 |
-
res.update({"abstract_top1": hits1 / n, "abstract_top3": hits3 / n,
|
| 299 |
-
"abstract_top1_parent": parent1 / n,
|
| 300 |
-
"abstract_preferred_top3": s3 / n,
|
| 301 |
-
"abstract_preferred_top5": s5 / n})
|
| 302 |
-
|
| 303 |
-
# Compact the artifact before saving. The bulk is not the vocabulary β it is
|
| 304 |
-
# 334 dense float64 coefficient vectors (334 x ~160k x 8 bytes β 427 MB
|
| 305 |
-
# before compression). Casting to float32 halves that with no measurable
|
| 306 |
-
# effect on ranking, and sparsify() drops the near-zero majority. This
|
| 307 |
-
# matters operationally: the Space pulls this file on every cold start.
|
| 308 |
-
for est in clf.estimators_:
|
| 309 |
-
est.coef_ = est.coef_.astype("float32")
|
| 310 |
-
est.sparsify()
|
| 311 |
-
joblib.dump(
|
| 312 |
-
{"word": word, "char": char, "clf": clf, "classes": mlb.classes_,
|
| 313 |
-
"names": names, "parent_of": parent_of, "corpus": args.corpus,
|
| 314 |
-
"metrics": res},
|
| 315 |
-
args.out, compress=3,
|
| 316 |
-
)
|
| 317 |
-
print(f"\nwrote {args.out} ({Path(args.out).stat().st_size/1e6:.1f} MB)")
|
| 318 |
-
|
| 319 |
-
# Sparsified coefficients change the predict path, so re-check the eval
|
| 320 |
-
# rather than assuming the compaction was lossless.
|
| 321 |
-
chk = joblib.load(args.out)
|
| 322 |
-
Xc = sparse.hstack([chk["word"].transform([X[0]]),
|
| 323 |
-
chk["char"].transform([X[0]])]).tocsr()
|
| 324 |
-
assert chk["clf"].predict_proba(Xc).shape[1] == len(mlb.classes_)
|
| 325 |
-
print("compacted model reloads and predicts correctly")
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
if __name__ == "__main__":
|
| 329 |
-
main()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Train the abstract -> ASJC multi-label classifier.
|
| 3 |
+
|
| 4 |
+
# interim model, no network needed β trains on journal titles in the index
|
| 5 |
+
python train_asjc.py --corpus titles --db journals.db --out asjc_model.joblib
|
| 6 |
+
|
| 7 |
+
# full model, after harvest_openalex.py has produced labelled abstracts
|
| 8 |
+
python train_asjc.py --corpus abstracts --train-jsonl asjc_train.jsonl \
|
| 9 |
+
--db journals.db --out asjc_model.joblib
|
| 10 |
+
|
| 11 |
+
Why TF-IDF + linear and not a transformer
|
| 12 |
+
-----------------------------------------
|
| 13 |
+
The task is topical multi-label text classification over 334 labels with a large
|
| 14 |
+
vocabulary signal β exactly where a linear model over character and word n-grams
|
| 15 |
+
is hard to beat per unit of cost. It trains in seconds on CPU, the artifact is
|
| 16 |
+
tens of MB, and inference is sub-millisecond with no GPU and no model download at
|
| 17 |
+
Space startup. A fine-tuned transformer might add a few points of F1 for ~400MB
|
| 18 |
+
and a cold-start penalty on every Space restart. Take the linear model; spend the
|
| 19 |
+
complexity budget on the LLM fallback for the cases it actually gets wrong.
|
| 20 |
+
|
| 21 |
+
Two corpora, deliberately
|
| 22 |
+
-------------------------
|
| 23 |
+
`titles` trains on journal titles (48,888 labelled examples, already in the
|
| 24 |
+
index, zero network). Ships immediately. But it trains on titles and
|
| 25 |
+
is applied to abstracts, and that domain gap is real β treat its
|
| 26 |
+
numbers as an upper bound on what it will do in production.
|
| 27 |
+
|
| 28 |
+
`abstracts` trains on article abstracts labelled with their journal's ASJC codes
|
| 29 |
+
(weak supervision). Far better matched to inference, and the only
|
| 30 |
+
version whose held-out score means what it appears to mean.
|
| 31 |
+
|
| 32 |
+
Label noise in the `abstracts` corpus
|
| 33 |
+
-------------------------------------
|
| 34 |
+
Giving every article its journal's codes is weak supervision: a physics paper in
|
| 35 |
+
a multidisciplinary journal inherits 'Medicine' too. `--max-labels` drops journals
|
| 36 |
+
carrying more than N ASJC codes from training, which removes the worst offenders
|
| 37 |
+
(megajournals and general-science titles) at the cost of some coverage. Their
|
| 38 |
+
articles are still *classifiable* at inference β they are just poor teachers.
|
| 39 |
+
"""
|
| 40 |
+
from __future__ import annotations
|
| 41 |
+
|
| 42 |
+
import argparse
|
| 43 |
+
import json
|
| 44 |
+
import re
|
| 45 |
+
import sqlite3
|
| 46 |
+
import sys
|
| 47 |
+
from pathlib import Path
|
| 48 |
+
|
| 49 |
+
import numpy as np
|
| 50 |
+
from scipy import sparse
|
| 51 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 52 |
+
from sklearn.linear_model import SGDClassifier
|
| 53 |
+
from sklearn.model_selection import train_test_split
|
| 54 |
+
from sklearn.multiclass import OneVsRestClassifier
|
| 55 |
+
from sklearn.preprocessing import MultiLabelBinarizer
|
| 56 |
+
|
| 57 |
+
import joblib
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
# Corpora
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
def load_titles(db: str, max_labels: int) -> tuple[list[str], list[list[str]]]:
|
| 64 |
+
"""Journal title + subject names -> its ASJC codes."""
|
| 65 |
+
con = sqlite3.connect(db)
|
| 66 |
+
con.row_factory = sqlite3.Row
|
| 67 |
+
rows = con.execute(
|
| 68 |
+
"""SELECT j.scopus_id, j.title, j.publisher_group,
|
| 69 |
+
group_concat(ja.asjc) codes, count(*) n
|
| 70 |
+
FROM journals j JOIN journal_asjc ja ON ja.scopus_id = j.scopus_id
|
| 71 |
+
WHERE j.title IS NOT NULL
|
| 72 |
+
GROUP BY j.scopus_id"""
|
| 73 |
+
).fetchall()
|
| 74 |
+
X, y = [], []
|
| 75 |
+
for r in rows:
|
| 76 |
+
if r["n"] > max_labels:
|
| 77 |
+
continue
|
| 78 |
+
X.append(r["title"])
|
| 79 |
+
y.append(r["codes"].split(","))
|
| 80 |
+
con.close()
|
| 81 |
+
return X, y
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def load_abstracts(path: str, db: str, max_labels: int) -> tuple[list[str], list[list[str]]]:
|
| 85 |
+
"""
|
| 86 |
+
JSONL from harvest_openalex.py: {"text": ..., "scopus_id": ...}.
|
| 87 |
+
Labels come from the index, so the harvest never has to carry them.
|
| 88 |
+
"""
|
| 89 |
+
con = sqlite3.connect(db)
|
| 90 |
+
codes: dict[str, list[str]] = {}
|
| 91 |
+
for sid, c in con.execute(
|
| 92 |
+
"SELECT scopus_id, group_concat(asjc) FROM journal_asjc GROUP BY scopus_id"
|
| 93 |
+
):
|
| 94 |
+
codes[sid] = c.split(",")
|
| 95 |
+
con.close()
|
| 96 |
+
|
| 97 |
+
X, y = [], []
|
| 98 |
+
with open(path, encoding="utf-8") as f:
|
| 99 |
+
for line in f:
|
| 100 |
+
rec = json.loads(line)
|
| 101 |
+
c = codes.get(rec.get("scopus_id"))
|
| 102 |
+
if not c or len(c) > max_labels:
|
| 103 |
+
continue
|
| 104 |
+
t = (rec.get("text") or "").strip()
|
| 105 |
+
if len(t) < 120: # a bare title is not an abstract
|
| 106 |
+
continue
|
| 107 |
+
X.append(t)
|
| 108 |
+
y.append(c)
|
| 109 |
+
return X, y
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
# Evaluation
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
def evaluate(clf, Xv, Y_true, classes: np.ndarray, parent_of: dict[str, str]) -> dict:
|
| 116 |
+
"""
|
| 117 |
+
Ranking metrics, not threshold metrics.
|
| 118 |
+
|
| 119 |
+
The classifier feeds a *filter*, and the filter takes the top few codes β so
|
| 120 |
+
what matters is whether a correct code is ranked highly, not whether some
|
| 121 |
+
probability cleared 0.5. Reporting only micro-F1 at a fixed threshold would
|
| 122 |
+
flatter a model that is well-calibrated but badly ordered, and vice versa.
|
| 123 |
+
|
| 124 |
+
Parent accuracy (2-digit ASJC) is reported alongside because confusing
|
| 125 |
+
'Information Systems' with 'Computer Science Applications' barely harms the
|
| 126 |
+
candidate set, while confusing Computer Science with Dentistry destroys it.
|
| 127 |
+
"""
|
| 128 |
+
P = clf.predict_proba(Xv)
|
| 129 |
+
order = np.argsort(-P, axis=1)
|
| 130 |
+
res: dict[str, float] = {}
|
| 131 |
+
|
| 132 |
+
for k in (1, 3, 5):
|
| 133 |
+
topk = order[:, :k]
|
| 134 |
+
hit = sum(
|
| 135 |
+
bool(set(classes[topk[i]]) & set(Y_true[i])) for i in range(len(Y_true))
|
| 136 |
+
)
|
| 137 |
+
res[f"top{k}_hit"] = hit / len(Y_true)
|
| 138 |
+
|
| 139 |
+
# Recall of the full true label set within the top 5 predictions.
|
| 140 |
+
top5 = order[:, :5]
|
| 141 |
+
res["top5_label_recall"] = float(np.mean([
|
| 142 |
+
len(set(classes[top5[i]]) & set(Y_true[i])) / len(Y_true[i])
|
| 143 |
+
for i in range(len(Y_true))
|
| 144 |
+
]))
|
| 145 |
+
|
| 146 |
+
top1_parent = [parent_of.get(classes[order[i, 0]]) for i in range(len(Y_true))]
|
| 147 |
+
res["top1_parent_hit"] = float(np.mean([
|
| 148 |
+
top1_parent[i] in {parent_of.get(c) for c in Y_true[i]}
|
| 149 |
+
for i in range(len(Y_true))
|
| 150 |
+
]))
|
| 151 |
+
return res
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def main() -> None:
|
| 155 |
+
ap = argparse.ArgumentParser()
|
| 156 |
+
ap.add_argument("--corpus", choices=["titles", "abstracts"], default="titles")
|
| 157 |
+
ap.add_argument("--db", default="journals.db")
|
| 158 |
+
ap.add_argument("--train-jsonl", default="asjc_train.jsonl")
|
| 159 |
+
ap.add_argument("--out", default="asjc_model.joblib")
|
| 160 |
+
ap.add_argument("--max-labels", type=int, default=6,
|
| 161 |
+
help="drop journals carrying more ASJC codes than this from training")
|
| 162 |
+
ap.add_argument("--min-per-label", type=int, default=8,
|
| 163 |
+
help="drop labels with fewer training examples than this")
|
| 164 |
+
ap.add_argument("--eval-jsonl", default="asjc_eval.jsonl")
|
| 165 |
+
ap.add_argument("--max-features", type=int, default=120_000)
|
| 166 |
+
ap.add_argument("--n-jobs", type=int, default=2,
|
| 167 |
+
help="parallel per-label fits. 334 estimators over a large sparse "
|
| 168 |
+
"matrix can exhaust memory at -1; 4 is safe on a 16GB Space.")
|
| 169 |
+
args = ap.parse_args()
|
| 170 |
+
|
| 171 |
+
if args.corpus == "titles":
|
| 172 |
+
X, y = load_titles(args.db, args.max_labels)
|
| 173 |
+
else:
|
| 174 |
+
if not Path(args.train_jsonl).exists():
|
| 175 |
+
sys.exit(f"{args.train_jsonl} not found β run harvest_openalex.py first")
|
| 176 |
+
X, y = load_abstracts(args.train_jsonl, args.db, args.max_labels)
|
| 177 |
+
|
| 178 |
+
print(f"corpus={args.corpus} examples={len(X)} "
|
| 179 |
+
f"mean labels/example={np.mean([len(c) for c in y]):.2f}")
|
| 180 |
+
|
| 181 |
+
# Drop labels too rare to learn. They stay resolvable through the LLM
|
| 182 |
+
# fallback, which does not need training examples.
|
| 183 |
+
from collections import Counter
|
| 184 |
+
freq = Counter(c for cs in y for c in cs)
|
| 185 |
+
keep = {c for c, n in freq.items() if n >= args.min_per_label}
|
| 186 |
+
y = [[c for c in cs if c in keep] for cs in y]
|
| 187 |
+
mask = [i for i, cs in enumerate(y) if cs]
|
| 188 |
+
X, y = [X[i] for i in mask], [y[i] for i in mask]
|
| 189 |
+
print(f"labels kept: {len(keep)} of {len(freq)} examples after filtering: {len(X)}")
|
| 190 |
+
|
| 191 |
+
mlb = MultiLabelBinarizer()
|
| 192 |
+
Y = mlb.fit_transform(y)
|
| 193 |
+
|
| 194 |
+
Xtr, Xte, Ytr, Yte, ytr_raw, yte_raw = train_test_split(
|
| 195 |
+
X, Y, y, test_size=0.2, random_state=42
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# Word n-grams carry the topical signal; character n-grams cover
|
| 199 |
+
# morphology, non-English titles and the domain shift between titles and
|
| 200 |
+
# abstracts. Union of both, sublinear tf to damp long abstracts.
|
| 201 |
+
word = TfidfVectorizer(
|
| 202 |
+
sublinear_tf=True, strip_accents="unicode", lowercase=True,
|
| 203 |
+
ngram_range=(1, 2), min_df=2, max_features=args.max_features,
|
| 204 |
+
stop_words="english", dtype=np.float32,
|
| 205 |
+
)
|
| 206 |
+
char = TfidfVectorizer(
|
| 207 |
+
sublinear_tf=True, strip_accents="unicode", lowercase=True,
|
| 208 |
+
analyzer="char_wb", ngram_range=(3, 5), min_df=3,
|
| 209 |
+
max_features=args.max_features // 3, dtype=np.float32,
|
| 210 |
+
)
|
| 211 |
+
Xtr_v = sparse.hstack([word.fit_transform(Xtr), char.fit_transform(Xtr)]).tocsr()
|
| 212 |
+
Xte_v = sparse.hstack([word.transform(Xte), char.transform(Xte)]).tocsr()
|
| 213 |
+
print(f"features: {Xtr_v.shape[1]}")
|
| 214 |
+
|
| 215 |
+
# SGD with modified_huber rather than liblinear logistic regression.
|
| 216 |
+
# 334 one-vs-rest fits over ~450k sparse features takes liblinear many
|
| 217 |
+
# minutes and it trips joblib's read-only memmap under parallel fan-out;
|
| 218 |
+
# SGD fits the same problem in seconds, scales to the ~1M-abstract corpus
|
| 219 |
+
# the harvest will produce, and modified_huber still exposes predict_proba,
|
| 220 |
+
# which the LLM fallback needs to decide when to escalate.
|
| 221 |
+
# log_loss, not modified_huber. modified_huber's predict_proba clips the
|
| 222 |
+
# margin and normalises, so it returns a saturated 1.0/0.0 with nothing in
|
| 223 |
+
# between β fine for ranking, useless for deciding when to escalate to the
|
| 224 |
+
# LLM. log_loss gives genuine logistic probabilities that a threshold can
|
| 225 |
+
# act on.
|
| 226 |
+
#
|
| 227 |
+
# class_weight is left unbalanced on purpose. Balancing inflates scores for
|
| 228 |
+
# rare labels, which is right for per-label F1 and wrong here: it pushed
|
| 229 |
+
# 'Rehabilitation' to the top of a cardiology abstract. The output feeds a
|
| 230 |
+
# ranked filter, so ordering matters more than per-label parity.
|
| 231 |
+
clf = OneVsRestClassifier(
|
| 232 |
+
SGDClassifier(
|
| 233 |
+
loss="log_loss", alpha=1e-6, max_iter=30, tol=1e-4,
|
| 234 |
+
random_state=42,
|
| 235 |
+
),
|
| 236 |
+
n_jobs=args.n_jobs,
|
| 237 |
+
)
|
| 238 |
+
with joblib.parallel_config(backend="loky", max_nbytes=None):
|
| 239 |
+
clf.fit(Xtr_v, Ytr)
|
| 240 |
+
|
| 241 |
+
con = sqlite3.connect(args.db)
|
| 242 |
+
parent_of = {c: p for c, p in con.execute("SELECT code, parent_code FROM asjc")}
|
| 243 |
+
names = {c: (n or pn) for c, n, pn in
|
| 244 |
+
con.execute("SELECT code, name, parent_name FROM asjc")}
|
| 245 |
+
con.close()
|
| 246 |
+
|
| 247 |
+
res = evaluate(clf, Xte_v, yte_raw, mlb.classes_, parent_of)
|
| 248 |
+
print(f"\nheld-out performance (same distribution as training: {args.corpus})")
|
| 249 |
+
for k, v in res.items():
|
| 250 |
+
print(f" {k:<20} {v:.3f}")
|
| 251 |
+
|
| 252 |
+
# The number that actually matters. When training on `titles`, the held-out
|
| 253 |
+
# score above is measured on 5-word journal titles while production input is
|
| 254 |
+
# a 200-word abstract β a different and much easier problem. This second
|
| 255 |
+
# evaluation runs on hand-labelled abstracts across 20 fields, which is the
|
| 256 |
+
# distribution the classifier is really deployed against.
|
| 257 |
+
if Path(args.eval_jsonl).exists():
|
| 258 |
+
ev = [json.loads(l) for l in open(args.eval_jsonl, encoding="utf-8")]
|
| 259 |
+
Xe = sparse.hstack([word.transform([e["text"] for e in ev]),
|
| 260 |
+
char.transform([e["text"] for e in ev])]).tocsr()
|
| 261 |
+
Pe = clf.predict_proba(Xe)
|
| 262 |
+
order = np.argsort(-Pe, axis=1)
|
| 263 |
+
# Two standards, reported separately and deliberately.
|
| 264 |
+
#
|
| 265 |
+
# LENIENT counts a hit when *any* acceptable code appears. It overstates
|
| 266 |
+
# usefulness: for a seismic-engineering abstract, 'Engineering
|
| 267 |
+
# (miscellaneous)' is in `accept` and scores a hit, but filtering on it
|
| 268 |
+
# returns nonlinear-analysis maths journals, while the preferred code
|
| 269 |
+
# 2205 returns concrete-structures journals. Same "hit", different
|
| 270 |
+
# recommendation quality.
|
| 271 |
+
#
|
| 272 |
+
# STRICT requires the single best code β accept[0], the one a subject
|
| 273 |
+
# librarian would choose. This is the number to watch when retraining on
|
| 274 |
+
# the abstract corpus, because it is the one that tracks whether the
|
| 275 |
+
# recommendations are actually good.
|
| 276 |
+
n = len(ev)
|
| 277 |
+
hits1 = hits3 = parent1 = 0
|
| 278 |
+
s5 = s3 = 0
|
| 279 |
+
print(f"\nabstract eval ({n} hand-labelled abstracts)")
|
| 280 |
+
for i, e in enumerate(ev):
|
| 281 |
+
top5 = [mlb.classes_[j] for j in order[i, :5]]
|
| 282 |
+
top3, top1 = top5[:3], top5[0]
|
| 283 |
+
pref = e["accept"][0]
|
| 284 |
+
ok1 = top1 in e["accept"]
|
| 285 |
+
ok3 = bool(set(top3) & set(e["accept"]))
|
| 286 |
+
okp = parent_of.get(top1) in {parent_of.get(c) for c in e["accept"]}
|
| 287 |
+
hits1 += ok1; hits3 += ok3; parent1 += okp
|
| 288 |
+
s5 += pref in top5
|
| 289 |
+
s3 += pref in top3
|
| 290 |
+
flag = "OK " if pref in top5 else ("weak" if ok3 else "MISS")
|
| 291 |
+
print(f" {flag} {e['field'][:28]:<28} pref {pref} "
|
| 292 |
+
f"{'in top5' if pref in top5 else 'ABSENT':<8} -> "
|
| 293 |
+
f"{', '.join(f'{c}' for c in top3)}")
|
| 294 |
+
print(f" lenient: top1 {hits1/n:.2f} top3 {hits3/n:.2f} parent {parent1/n:.2f}")
|
| 295 |
+
print(f" STRICT : preferred-in-top3 {s3/n:.2f} preferred-in-top5 {s5/n:.2f}")
|
| 296 |
+
print(" strict preferred-in-top5 is the number that gates shipping β it "
|
| 297 |
+
"bounds what the LLM stage can possibly pick.")
|
| 298 |
+
res.update({"abstract_top1": hits1 / n, "abstract_top3": hits3 / n,
|
| 299 |
+
"abstract_top1_parent": parent1 / n,
|
| 300 |
+
"abstract_preferred_top3": s3 / n,
|
| 301 |
+
"abstract_preferred_top5": s5 / n})
|
| 302 |
+
|
| 303 |
+
# Compact the artifact before saving. The bulk is not the vocabulary β it is
|
| 304 |
+
# 334 dense float64 coefficient vectors (334 x ~160k x 8 bytes β 427 MB
|
| 305 |
+
# before compression). Casting to float32 halves that with no measurable
|
| 306 |
+
# effect on ranking, and sparsify() drops the near-zero majority. This
|
| 307 |
+
# matters operationally: the Space pulls this file on every cold start.
|
| 308 |
+
for est in clf.estimators_:
|
| 309 |
+
est.coef_ = est.coef_.astype("float32")
|
| 310 |
+
est.sparsify()
|
| 311 |
+
joblib.dump(
|
| 312 |
+
{"word": word, "char": char, "clf": clf, "classes": mlb.classes_,
|
| 313 |
+
"names": names, "parent_of": parent_of, "corpus": args.corpus,
|
| 314 |
+
"metrics": res},
|
| 315 |
+
args.out, compress=3,
|
| 316 |
+
)
|
| 317 |
+
print(f"\nwrote {args.out} ({Path(args.out).stat().st_size/1e6:.1f} MB)")
|
| 318 |
+
|
| 319 |
+
# Sparsified coefficients change the predict path, so re-check the eval
|
| 320 |
+
# rather than assuming the compaction was lossless.
|
| 321 |
+
chk = joblib.load(args.out)
|
| 322 |
+
Xc = sparse.hstack([chk["word"].transform([X[0]]),
|
| 323 |
+
chk["char"].transform([X[0]])]).tocsr()
|
| 324 |
+
assert chk["clf"].predict_proba(Xc).shape[1] == len(mlb.classes_)
|
| 325 |
+
print("compacted model reloads and predicts correctly")
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
if __name__ == "__main__":
|
| 329 |
+
main()
|
usage.py
CHANGED
|
@@ -1,355 +1,355 @@
|
|
| 1 |
-
"""
|
| 2 |
-
usage.py β ResearchBee usage counting
|
| 3 |
-
|
| 4 |
-
Counts only. There is no query-text path through this module and no
|
| 5 |
-
column to put one in: the manuscript tab receives unpublished abstracts,
|
| 6 |
-
and the cheapest guarantee that those are never persisted is to have
|
| 7 |
-
nowhere to persist them.
|
| 8 |
-
|
| 9 |
-
Design notes
|
| 10 |
-
------------
|
| 11 |
-
1. Counting happens in memory and costs a dict update. Nothing touches
|
| 12 |
-
the network on the request path.
|
| 13 |
-
|
| 14 |
-
2. Deltas are flushed to the Cloudflare Worker every FLUSH_SECONDS.
|
| 15 |
-
A failed flush is NOT lost: the counts are folded back into the
|
| 16 |
-
pending dict and go out with the next attempt. Because we send
|
| 17 |
-
accumulated deltas rather than one row per request, an outage costs
|
| 18 |
-
a delay in the dashboard, not data. This is the main reason the
|
| 19 |
-
aggregate design is worth having β LibBee's per-row logging had to
|
| 20 |
-
grow a 200-item retry buffer to get the same property, and still
|
| 21 |
-
drops rows past that bound.
|
| 22 |
-
|
| 23 |
-
3. HF Spaces egress to workers.dev is known to fail intermittently
|
| 24 |
-
(LibBee v3.9.2 moved its logging into the browser for exactly this
|
| 25 |
-
reason). Retry-with-accumulation handles that without needing a
|
| 26 |
-
client-side path, so nothing has to change in the LibGuides fragment.
|
| 27 |
-
|
| 28 |
-
4. Counters do NOT survive a container restart if a flush has not
|
| 29 |
-
happened yet β at most FLUSH_SECONDS of activity is lost on a hard
|
| 30 |
-
restart. The 6-hour canary keeps the Space warm, so restarts should
|
| 31 |
-
be rare.
|
| 32 |
-
"""
|
| 33 |
-
|
| 34 |
-
from __future__ import annotations
|
| 35 |
-
|
| 36 |
-
import asyncio
|
| 37 |
-
import logging
|
| 38 |
-
import os
|
| 39 |
-
import time
|
| 40 |
-
from collections import deque
|
| 41 |
-
from datetime import datetime
|
| 42 |
-
from typing import Dict, List, Tuple
|
| 43 |
-
|
| 44 |
-
logger = logging.getLogger(__name__)
|
| 45 |
-
|
| 46 |
-
WORKER_URL = (os.environ.get("CLOUDFLARE_WORKER_URL") or "").strip().strip('"').strip("'").rstrip("/")
|
| 47 |
-
WORKER_TOKEN = (os.environ.get("CLOUDFLARE_WORKER_TOKEN") or "").strip()
|
| 48 |
-
|
| 49 |
-
FLUSH_SECONDS = 60
|
| 50 |
-
MAX_ATTEMPTS_LOG = 15
|
| 51 |
-
|
| 52 |
-
# ββ Pull mode βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
-
# This Space cannot open a connection to workers.dev: three probes failed
|
| 54 |
-
# identically at 15s with ConnectError, while OpenAI, OpenAlex and
|
| 55 |
-
# huggingface.co all work from the same container. Cloudflare throttles
|
| 56 |
-
# shared workers.dev addresses from datacentre IPs. LibBee hit this too,
|
| 57 |
-
# and forcing IPv4 made it worse, so that route is closed.
|
| 58 |
-
#
|
| 59 |
-
# Inbound still works β Cloudflare reaches this Space fine, which is how
|
| 60 |
-
# the canary runs. So the Worker PULLS counts on its 6-hour cron instead
|
| 61 |
-
# of this process pushing them. Pushing is left available but off by
|
| 62 |
-
# default: set USAGE_PUSH_ENABLED=true if egress is ever restored.
|
| 63 |
-
PUSH_ENABLED = (os.environ.get("USAGE_PUSH_ENABLED") or "").lower() in ("1", "true", "yes")
|
| 64 |
-
|
| 65 |
-
# Two-phase handover. A snapshot moves out of _pending into _in_flight and
|
| 66 |
-
# is only discarded once the Worker confirms it reached D1. If the Worker
|
| 67 |
-
# dies between reading and acknowledging, the snapshot is folded back in
|
| 68 |
-
# rather than lost.
|
| 69 |
-
IN_FLIGHT_TIMEOUT = 900 # 15 minutes
|
| 70 |
-
|
| 71 |
-
# Endpoint β surface. Adding an endpoint here is the only step needed to
|
| 72 |
-
# start counting it; the Worker validates the name and rejects unknowns,
|
| 73 |
-
# so a typo surfaces as a visible 400 rather than a phantom column.
|
| 74 |
-
SURFACE_BY_PATH: Dict[str, str] = {
|
| 75 |
-
"/api/browse-subject": "journal_subject",
|
| 76 |
-
"/api/analyze-journal": "journal_manuscript",
|
| 77 |
-
"/api/check-license": "licence_deposit",
|
| 78 |
-
"/api/find-repository": "data_repository",
|
| 79 |
-
"/api/cover-letter": "cover_letter",
|
| 80 |
-
"/api/chat": "chat",
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
# Key: (surface, outcome, gap_code)
|
| 84 |
-
_Key = Tuple[str, str, str]
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
class UsageCounters:
|
| 88 |
-
def __init__(self) -> None:
|
| 89 |
-
self._pending: Dict[_Key, int] = {}
|
| 90 |
-
self._lifetime: Dict[_Key, int] = {}
|
| 91 |
-
self._lock = asyncio.Lock()
|
| 92 |
-
self._attempts: deque = deque(maxlen=MAX_ATTEMPTS_LOG)
|
| 93 |
-
self._task: asyncio.Task | None = None
|
| 94 |
-
self._started_at = datetime.utcnow()
|
| 95 |
-
self._in_flight: Dict[str, tuple] = {} # id -> (unix_ts, {key: n})
|
| 96 |
-
|
| 97 |
-
# ββ Recording (sync, no I/O) ββββββββββββββββββββββββββββββββββββββ
|
| 98 |
-
|
| 99 |
-
def record(self, surface: str, outcome: str = "ok", gap_code: str = "") -> None:
|
| 100 |
-
"""Count one event. Never raises β instrumentation must not be
|
| 101 |
-
able to fail a user request."""
|
| 102 |
-
try:
|
| 103 |
-
if surface not in SURFACE_BY_PATH.values():
|
| 104 |
-
return
|
| 105 |
-
if outcome not in ("ok", "miss", "error"):
|
| 106 |
-
outcome = "error"
|
| 107 |
-
key = (surface, outcome, (gap_code or "")[:40])
|
| 108 |
-
self._pending[key] = self._pending.get(key, 0) + 1
|
| 109 |
-
self._lifetime[key] = self._lifetime.get(key, 0) + 1
|
| 110 |
-
except Exception:
|
| 111 |
-
pass
|
| 112 |
-
|
| 113 |
-
def record_gap(self, surface: str, gap_code: str) -> None:
|
| 114 |
-
"""A request that completed but could not be answered from the
|
| 115 |
-
available sources β a SCImago subject with no matches, an
|
| 116 |
-
OA.Works 404, a DOI that resolves to nothing. Recorded as its own
|
| 117 |
-
outcome so 'served a page' and 'answered the question' stay
|
| 118 |
-
distinguishable in the dashboard."""
|
| 119 |
-
self.record(surface, outcome="miss", gap_code=gap_code)
|
| 120 |
-
|
| 121 |
-
# ββ Local snapshot (works with no Worker configured) ββββββββββββββ
|
| 122 |
-
|
| 123 |
-
def snapshot(self) -> dict:
|
| 124 |
-
by_surface: Dict[str, Dict[str, int]] = {}
|
| 125 |
-
gaps: Dict[str, int] = {}
|
| 126 |
-
for (surface, outcome, gap), n in self._lifetime.items():
|
| 127 |
-
s = by_surface.setdefault(
|
| 128 |
-
surface, {"total": 0, "ok": 0, "miss": 0, "error": 0}
|
| 129 |
-
)
|
| 130 |
-
s["total"] += n
|
| 131 |
-
s[outcome] = s.get(outcome, 0) + n
|
| 132 |
-
if gap:
|
| 133 |
-
gaps[gap] = gaps.get(gap, 0) + n
|
| 134 |
-
return {
|
| 135 |
-
"since": self._started_at.strftime("%Y-%m-%d %H:%M:%S") + " UTC",
|
| 136 |
-
"note": "Counts since this container last started. "
|
| 137 |
-
"Durable totals come from the Worker.",
|
| 138 |
-
"by_surface": by_surface,
|
| 139 |
-
"gaps": dict(sorted(gaps.items(), key=lambda kv: -kv[1])),
|
| 140 |
-
"pending_deltas": len(self._pending),
|
| 141 |
-
"in_flight_snapshots": len(self._in_flight),
|
| 142 |
-
"mode": "push" if PUSH_ENABLED else "pull (Worker fetches on its cron)",
|
| 143 |
-
"worker_configured": bool(WORKER_URL),
|
| 144 |
-
"recent_flushes": list(self._attempts)[::-1],
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
# ββ Pull handover βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 148 |
-
|
| 149 |
-
def export_snapshot(self) -> dict:
|
| 150 |
-
"""Hand the accumulated deltas to a caller that will store them.
|
| 151 |
-
|
| 152 |
-
The counts move to _in_flight rather than being deleted, so a
|
| 153 |
-
caller that reads them and then fails does not silently destroy
|
| 154 |
-
them. Call ack_snapshot() once they are safely written.
|
| 155 |
-
"""
|
| 156 |
-
self._requeue_stale()
|
| 157 |
-
if not self._pending:
|
| 158 |
-
return {"snapshot_id": None, "deltas": [], "pending": 0}
|
| 159 |
-
|
| 160 |
-
import uuid
|
| 161 |
-
snapshot_id = uuid.uuid4().hex[:16]
|
| 162 |
-
batch = self._pending
|
| 163 |
-
self._pending = {}
|
| 164 |
-
self._in_flight[snapshot_id] = (time.time(), batch)
|
| 165 |
-
|
| 166 |
-
return {
|
| 167 |
-
"snapshot_id": snapshot_id,
|
| 168 |
-
"deltas": [
|
| 169 |
-
{"surface": s, "outcome": o, "gap_code": g, "n": n}
|
| 170 |
-
for (s, o, g), n in batch.items()
|
| 171 |
-
],
|
| 172 |
-
"pending": 0,
|
| 173 |
-
"in_flight": len(self._in_flight),
|
| 174 |
-
}
|
| 175 |
-
|
| 176 |
-
def ack_snapshot(self, snapshot_id: str) -> bool:
|
| 177 |
-
"""Confirm a snapshot was stored. Only now is it discarded."""
|
| 178 |
-
existed = snapshot_id in self._in_flight
|
| 179 |
-
self._in_flight.pop(snapshot_id, None)
|
| 180 |
-
if existed:
|
| 181 |
-
self._record_attempt("pulled", f"snapshot {snapshot_id} acknowledged")
|
| 182 |
-
return existed
|
| 183 |
-
|
| 184 |
-
def _requeue_stale(self) -> None:
|
| 185 |
-
"""Fold back snapshots nobody acknowledged. The cost of a caller
|
| 186 |
-
crashing mid-handover is a delay and a possible double count on
|
| 187 |
-
the same hour bucket, never a loss."""
|
| 188 |
-
now = time.time()
|
| 189 |
-
for sid in [k for k, (ts, _) in self._in_flight.items()
|
| 190 |
-
if now - ts > IN_FLIGHT_TIMEOUT]:
|
| 191 |
-
_ts, batch = self._in_flight.pop(sid)
|
| 192 |
-
self._requeue(batch)
|
| 193 |
-
self._record_attempt("requeued", f"snapshot {sid} was never acknowledged")
|
| 194 |
-
|
| 195 |
-
# ββ Flushing ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 196 |
-
|
| 197 |
-
def _record_attempt(self, outcome: str, detail: str = "") -> None:
|
| 198 |
-
self._attempts.append({
|
| 199 |
-
"at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
| 200 |
-
"outcome": outcome,
|
| 201 |
-
"detail": detail[:200],
|
| 202 |
-
})
|
| 203 |
-
|
| 204 |
-
async def flush(self) -> bool:
|
| 205 |
-
"""Send accumulated deltas. On failure the deltas are folded back
|
| 206 |
-
in and retried on the next pass, so nothing is dropped."""
|
| 207 |
-
if not WORKER_URL or not self._pending:
|
| 208 |
-
return True
|
| 209 |
-
|
| 210 |
-
async with self._lock:
|
| 211 |
-
batch = self._pending
|
| 212 |
-
self._pending = {}
|
| 213 |
-
|
| 214 |
-
deltas: List[dict] = [
|
| 215 |
-
{"surface": s, "outcome": o, "gap_code": g, "n": n}
|
| 216 |
-
for (s, o, g), n in batch.items()
|
| 217 |
-
]
|
| 218 |
-
|
| 219 |
-
try:
|
| 220 |
-
import httpx
|
| 221 |
-
headers = {"Authorization": f"Bearer {WORKER_TOKEN}"} if WORKER_TOKEN else {}
|
| 222 |
-
async with httpx.AsyncClient(timeout=10) as client:
|
| 223 |
-
r = await client.post(
|
| 224 |
-
f"{WORKER_URL}/rb/log",
|
| 225 |
-
json={"deltas": deltas},
|
| 226 |
-
headers=headers,
|
| 227 |
-
)
|
| 228 |
-
body = {}
|
| 229 |
-
try:
|
| 230 |
-
body = r.json()
|
| 231 |
-
except Exception:
|
| 232 |
-
pass
|
| 233 |
-
|
| 234 |
-
# The Worker answers 200 with logged=false when the D1 write
|
| 235 |
-
# itself failed, so the status code alone cannot tell a stored
|
| 236 |
-
# batch from a dropped one. Check the verdict, not the code.
|
| 237 |
-
if r.status_code < 400 and body.get("logged"):
|
| 238 |
-
self._record_attempt(
|
| 239 |
-
"ok", f"{body.get('accepted', len(deltas))} delta(s) stored"
|
| 240 |
-
)
|
| 241 |
-
return True
|
| 242 |
-
|
| 243 |
-
self._requeue(batch)
|
| 244 |
-
if r.status_code == 401:
|
| 245 |
-
self._record_attempt(
|
| 246 |
-
"rejected_401",
|
| 247 |
-
"CLOUDFLARE_WORKER_TOKEN does not match the Worker's ANALYTICS_TOKEN",
|
| 248 |
-
)
|
| 249 |
-
else:
|
| 250 |
-
self._record_attempt(
|
| 251 |
-
"refused",
|
| 252 |
-
f"HTTP {r.status_code} {body.get('warning', '')}",
|
| 253 |
-
)
|
| 254 |
-
return False
|
| 255 |
-
|
| 256 |
-
except Exception as exc:
|
| 257 |
-
self._requeue(batch)
|
| 258 |
-
self._record_attempt("unreachable", f"{type(exc).__name__}: {exc}")
|
| 259 |
-
return False
|
| 260 |
-
|
| 261 |
-
def _requeue(self, batch: Dict[_Key, int]) -> None:
|
| 262 |
-
for key, n in batch.items():
|
| 263 |
-
self._pending[key] = self._pending.get(key, 0) + n
|
| 264 |
-
|
| 265 |
-
async def _loop(self) -> None:
|
| 266 |
-
while True:
|
| 267 |
-
await asyncio.sleep(FLUSH_SECONDS)
|
| 268 |
-
try:
|
| 269 |
-
await self.flush()
|
| 270 |
-
except Exception as exc:
|
| 271 |
-
logger.warning("usage flush loop error: %s", exc)
|
| 272 |
-
|
| 273 |
-
def start(self) -> None:
|
| 274 |
-
"""Start the background flusher, if pushing is enabled at all."""
|
| 275 |
-
if not PUSH_ENABLED:
|
| 276 |
-
logger.info("Usage counting active in PULL mode β the Worker "
|
| 277 |
-
"collects counts on its cron; no outbound calls made.")
|
| 278 |
-
return
|
| 279 |
-
if self._task is not None:
|
| 280 |
-
return
|
| 281 |
-
try:
|
| 282 |
-
loop = asyncio.get_running_loop()
|
| 283 |
-
except RuntimeError:
|
| 284 |
-
return
|
| 285 |
-
# A strong reference is held on the instance: asyncio keeps only
|
| 286 |
-
# weak references to tasks, and a dropped task can be collected
|
| 287 |
-
# mid-flight, silently cancelling the flush.
|
| 288 |
-
self._task = loop.create_task(self._loop())
|
| 289 |
-
logger.info("Usage flusher started (%ss interval, worker=%s)",
|
| 290 |
-
FLUSH_SECONDS, bool(WORKER_URL))
|
| 291 |
-
|
| 292 |
-
async def stop(self) -> None:
|
| 293 |
-
"""Final flush on shutdown so the last minute of activity lands.
|
| 294 |
-
In pull mode there is nowhere to flush to, so this is a no-op and
|
| 295 |
-
up to 6 hours of counts can be lost on an unlucky restart β the
|
| 296 |
-
dashboard's "Send counts to Cloudflare now" button exists to make
|
| 297 |
-
that recoverable before a planned redeploy."""
|
| 298 |
-
if self._task:
|
| 299 |
-
self._task.cancel()
|
| 300 |
-
self._task = None
|
| 301 |
-
if PUSH_ENABLED:
|
| 302 |
-
await self.flush()
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
usage = UsageCounters()
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
# ββ FastAPI middleware ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 309 |
-
|
| 310 |
-
def install(app) -> None:
|
| 311 |
-
"""Wire counting into the app. No frontend change is required: the
|
| 312 |
-
tab a person used is implied by the endpoint their browser called,
|
| 313 |
-
which is why this survives the move to LibGuides and any future
|
| 314 |
-
frontend rewrite."""
|
| 315 |
-
from starlette.requests import Request
|
| 316 |
-
|
| 317 |
-
@app.middleware("http")
|
| 318 |
-
async def _count(request: Request, call_next):
|
| 319 |
-
surface = SURFACE_BY_PATH.get(request.url.path)
|
| 320 |
-
# Canary traffic exists to keep the Space awake and must never
|
| 321 |
-
# appear in usage figures β the same exclusion LibBee applies.
|
| 322 |
-
counted = surface and request.headers.get("X-ResearchBee-Canary") != "1"
|
| 323 |
-
|
| 324 |
-
try:
|
| 325 |
-
response = await call_next(request)
|
| 326 |
-
except Exception:
|
| 327 |
-
# An unhandled exception propagates out of call_next, so
|
| 328 |
-
# anything written after the await would never run. Without
|
| 329 |
-
# this branch the most important failures β the ones that
|
| 330 |
-
# crash a handler β would be the only ones missing from the
|
| 331 |
-
# dashboard.
|
| 332 |
-
if counted:
|
| 333 |
-
usage.record(surface, "error", "unhandled_exception")
|
| 334 |
-
raise
|
| 335 |
-
|
| 336 |
-
try:
|
| 337 |
-
if counted:
|
| 338 |
-
if response.status_code < 400:
|
| 339 |
-
# A handler that recorded its own miss has already
|
| 340 |
-
# counted this request; the flag stops it counting twice.
|
| 341 |
-
if not getattr(request.state, "usage_recorded", False):
|
| 342 |
-
usage.record(surface, "ok")
|
| 343 |
-
else:
|
| 344 |
-
usage.record(surface, "error")
|
| 345 |
-
except Exception:
|
| 346 |
-
pass
|
| 347 |
-
return response
|
| 348 |
-
|
| 349 |
-
@app.on_event("startup")
|
| 350 |
-
async def _start_usage():
|
| 351 |
-
usage.start()
|
| 352 |
-
|
| 353 |
-
@app.on_event("shutdown")
|
| 354 |
-
async def _stop_usage():
|
| 355 |
await usage.stop()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
usage.py β ResearchBee usage counting
|
| 3 |
+
|
| 4 |
+
Counts only. There is no query-text path through this module and no
|
| 5 |
+
column to put one in: the manuscript tab receives unpublished abstracts,
|
| 6 |
+
and the cheapest guarantee that those are never persisted is to have
|
| 7 |
+
nowhere to persist them.
|
| 8 |
+
|
| 9 |
+
Design notes
|
| 10 |
+
------------
|
| 11 |
+
1. Counting happens in memory and costs a dict update. Nothing touches
|
| 12 |
+
the network on the request path.
|
| 13 |
+
|
| 14 |
+
2. Deltas are flushed to the Cloudflare Worker every FLUSH_SECONDS.
|
| 15 |
+
A failed flush is NOT lost: the counts are folded back into the
|
| 16 |
+
pending dict and go out with the next attempt. Because we send
|
| 17 |
+
accumulated deltas rather than one row per request, an outage costs
|
| 18 |
+
a delay in the dashboard, not data. This is the main reason the
|
| 19 |
+
aggregate design is worth having β LibBee's per-row logging had to
|
| 20 |
+
grow a 200-item retry buffer to get the same property, and still
|
| 21 |
+
drops rows past that bound.
|
| 22 |
+
|
| 23 |
+
3. HF Spaces egress to workers.dev is known to fail intermittently
|
| 24 |
+
(LibBee v3.9.2 moved its logging into the browser for exactly this
|
| 25 |
+
reason). Retry-with-accumulation handles that without needing a
|
| 26 |
+
client-side path, so nothing has to change in the LibGuides fragment.
|
| 27 |
+
|
| 28 |
+
4. Counters do NOT survive a container restart if a flush has not
|
| 29 |
+
happened yet β at most FLUSH_SECONDS of activity is lost on a hard
|
| 30 |
+
restart. The 6-hour canary keeps the Space warm, so restarts should
|
| 31 |
+
be rare.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import asyncio
|
| 37 |
+
import logging
|
| 38 |
+
import os
|
| 39 |
+
import time
|
| 40 |
+
from collections import deque
|
| 41 |
+
from datetime import datetime
|
| 42 |
+
from typing import Dict, List, Tuple
|
| 43 |
+
|
| 44 |
+
logger = logging.getLogger(__name__)
|
| 45 |
+
|
| 46 |
+
WORKER_URL = (os.environ.get("CLOUDFLARE_WORKER_URL") or "").strip().strip('"').strip("'").rstrip("/")
|
| 47 |
+
WORKER_TOKEN = (os.environ.get("CLOUDFLARE_WORKER_TOKEN") or "").strip()
|
| 48 |
+
|
| 49 |
+
FLUSH_SECONDS = 60
|
| 50 |
+
MAX_ATTEMPTS_LOG = 15
|
| 51 |
+
|
| 52 |
+
# ββ Pull mode βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
# This Space cannot open a connection to workers.dev: three probes failed
|
| 54 |
+
# identically at 15s with ConnectError, while OpenAI, OpenAlex and
|
| 55 |
+
# huggingface.co all work from the same container. Cloudflare throttles
|
| 56 |
+
# shared workers.dev addresses from datacentre IPs. LibBee hit this too,
|
| 57 |
+
# and forcing IPv4 made it worse, so that route is closed.
|
| 58 |
+
#
|
| 59 |
+
# Inbound still works β Cloudflare reaches this Space fine, which is how
|
| 60 |
+
# the canary runs. So the Worker PULLS counts on its 6-hour cron instead
|
| 61 |
+
# of this process pushing them. Pushing is left available but off by
|
| 62 |
+
# default: set USAGE_PUSH_ENABLED=true if egress is ever restored.
|
| 63 |
+
PUSH_ENABLED = (os.environ.get("USAGE_PUSH_ENABLED") or "").lower() in ("1", "true", "yes")
|
| 64 |
+
|
| 65 |
+
# Two-phase handover. A snapshot moves out of _pending into _in_flight and
|
| 66 |
+
# is only discarded once the Worker confirms it reached D1. If the Worker
|
| 67 |
+
# dies between reading and acknowledging, the snapshot is folded back in
|
| 68 |
+
# rather than lost.
|
| 69 |
+
IN_FLIGHT_TIMEOUT = 900 # 15 minutes
|
| 70 |
+
|
| 71 |
+
# Endpoint β surface. Adding an endpoint here is the only step needed to
|
| 72 |
+
# start counting it; the Worker validates the name and rejects unknowns,
|
| 73 |
+
# so a typo surfaces as a visible 400 rather than a phantom column.
|
| 74 |
+
SURFACE_BY_PATH: Dict[str, str] = {
|
| 75 |
+
"/api/browse-subject": "journal_subject",
|
| 76 |
+
"/api/analyze-journal": "journal_manuscript",
|
| 77 |
+
"/api/check-license": "licence_deposit",
|
| 78 |
+
"/api/find-repository": "data_repository",
|
| 79 |
+
"/api/cover-letter": "cover_letter",
|
| 80 |
+
"/api/chat": "chat",
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
# Key: (surface, outcome, gap_code)
|
| 84 |
+
_Key = Tuple[str, str, str]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class UsageCounters:
|
| 88 |
+
def __init__(self) -> None:
|
| 89 |
+
self._pending: Dict[_Key, int] = {}
|
| 90 |
+
self._lifetime: Dict[_Key, int] = {}
|
| 91 |
+
self._lock = asyncio.Lock()
|
| 92 |
+
self._attempts: deque = deque(maxlen=MAX_ATTEMPTS_LOG)
|
| 93 |
+
self._task: asyncio.Task | None = None
|
| 94 |
+
self._started_at = datetime.utcnow()
|
| 95 |
+
self._in_flight: Dict[str, tuple] = {} # id -> (unix_ts, {key: n})
|
| 96 |
+
|
| 97 |
+
# ββ Recording (sync, no I/O) ββββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
|
| 99 |
+
def record(self, surface: str, outcome: str = "ok", gap_code: str = "") -> None:
|
| 100 |
+
"""Count one event. Never raises β instrumentation must not be
|
| 101 |
+
able to fail a user request."""
|
| 102 |
+
try:
|
| 103 |
+
if surface not in SURFACE_BY_PATH.values():
|
| 104 |
+
return
|
| 105 |
+
if outcome not in ("ok", "miss", "error"):
|
| 106 |
+
outcome = "error"
|
| 107 |
+
key = (surface, outcome, (gap_code or "")[:40])
|
| 108 |
+
self._pending[key] = self._pending.get(key, 0) + 1
|
| 109 |
+
self._lifetime[key] = self._lifetime.get(key, 0) + 1
|
| 110 |
+
except Exception:
|
| 111 |
+
pass
|
| 112 |
+
|
| 113 |
+
def record_gap(self, surface: str, gap_code: str) -> None:
|
| 114 |
+
"""A request that completed but could not be answered from the
|
| 115 |
+
available sources β a SCImago subject with no matches, an
|
| 116 |
+
OA.Works 404, a DOI that resolves to nothing. Recorded as its own
|
| 117 |
+
outcome so 'served a page' and 'answered the question' stay
|
| 118 |
+
distinguishable in the dashboard."""
|
| 119 |
+
self.record(surface, outcome="miss", gap_code=gap_code)
|
| 120 |
+
|
| 121 |
+
# ββ Local snapshot (works with no Worker configured) ββββββββββββββ
|
| 122 |
+
|
| 123 |
+
def snapshot(self) -> dict:
|
| 124 |
+
by_surface: Dict[str, Dict[str, int]] = {}
|
| 125 |
+
gaps: Dict[str, int] = {}
|
| 126 |
+
for (surface, outcome, gap), n in self._lifetime.items():
|
| 127 |
+
s = by_surface.setdefault(
|
| 128 |
+
surface, {"total": 0, "ok": 0, "miss": 0, "error": 0}
|
| 129 |
+
)
|
| 130 |
+
s["total"] += n
|
| 131 |
+
s[outcome] = s.get(outcome, 0) + n
|
| 132 |
+
if gap:
|
| 133 |
+
gaps[gap] = gaps.get(gap, 0) + n
|
| 134 |
+
return {
|
| 135 |
+
"since": self._started_at.strftime("%Y-%m-%d %H:%M:%S") + " UTC",
|
| 136 |
+
"note": "Counts since this container last started. "
|
| 137 |
+
"Durable totals come from the Worker.",
|
| 138 |
+
"by_surface": by_surface,
|
| 139 |
+
"gaps": dict(sorted(gaps.items(), key=lambda kv: -kv[1])),
|
| 140 |
+
"pending_deltas": len(self._pending),
|
| 141 |
+
"in_flight_snapshots": len(self._in_flight),
|
| 142 |
+
"mode": "push" if PUSH_ENABLED else "pull (Worker fetches on its cron)",
|
| 143 |
+
"worker_configured": bool(WORKER_URL),
|
| 144 |
+
"recent_flushes": list(self._attempts)[::-1],
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
# ββ Pull handover βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 148 |
+
|
| 149 |
+
def export_snapshot(self) -> dict:
|
| 150 |
+
"""Hand the accumulated deltas to a caller that will store them.
|
| 151 |
+
|
| 152 |
+
The counts move to _in_flight rather than being deleted, so a
|
| 153 |
+
caller that reads them and then fails does not silently destroy
|
| 154 |
+
them. Call ack_snapshot() once they are safely written.
|
| 155 |
+
"""
|
| 156 |
+
self._requeue_stale()
|
| 157 |
+
if not self._pending:
|
| 158 |
+
return {"snapshot_id": None, "deltas": [], "pending": 0}
|
| 159 |
+
|
| 160 |
+
import uuid
|
| 161 |
+
snapshot_id = uuid.uuid4().hex[:16]
|
| 162 |
+
batch = self._pending
|
| 163 |
+
self._pending = {}
|
| 164 |
+
self._in_flight[snapshot_id] = (time.time(), batch)
|
| 165 |
+
|
| 166 |
+
return {
|
| 167 |
+
"snapshot_id": snapshot_id,
|
| 168 |
+
"deltas": [
|
| 169 |
+
{"surface": s, "outcome": o, "gap_code": g, "n": n}
|
| 170 |
+
for (s, o, g), n in batch.items()
|
| 171 |
+
],
|
| 172 |
+
"pending": 0,
|
| 173 |
+
"in_flight": len(self._in_flight),
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
def ack_snapshot(self, snapshot_id: str) -> bool:
|
| 177 |
+
"""Confirm a snapshot was stored. Only now is it discarded."""
|
| 178 |
+
existed = snapshot_id in self._in_flight
|
| 179 |
+
self._in_flight.pop(snapshot_id, None)
|
| 180 |
+
if existed:
|
| 181 |
+
self._record_attempt("pulled", f"snapshot {snapshot_id} acknowledged")
|
| 182 |
+
return existed
|
| 183 |
+
|
| 184 |
+
def _requeue_stale(self) -> None:
|
| 185 |
+
"""Fold back snapshots nobody acknowledged. The cost of a caller
|
| 186 |
+
crashing mid-handover is a delay and a possible double count on
|
| 187 |
+
the same hour bucket, never a loss."""
|
| 188 |
+
now = time.time()
|
| 189 |
+
for sid in [k for k, (ts, _) in self._in_flight.items()
|
| 190 |
+
if now - ts > IN_FLIGHT_TIMEOUT]:
|
| 191 |
+
_ts, batch = self._in_flight.pop(sid)
|
| 192 |
+
self._requeue(batch)
|
| 193 |
+
self._record_attempt("requeued", f"snapshot {sid} was never acknowledged")
|
| 194 |
+
|
| 195 |
+
# ββ Flushing ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 196 |
+
|
| 197 |
+
def _record_attempt(self, outcome: str, detail: str = "") -> None:
|
| 198 |
+
self._attempts.append({
|
| 199 |
+
"at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
| 200 |
+
"outcome": outcome,
|
| 201 |
+
"detail": detail[:200],
|
| 202 |
+
})
|
| 203 |
+
|
| 204 |
+
async def flush(self) -> bool:
|
| 205 |
+
"""Send accumulated deltas. On failure the deltas are folded back
|
| 206 |
+
in and retried on the next pass, so nothing is dropped."""
|
| 207 |
+
if not WORKER_URL or not self._pending:
|
| 208 |
+
return True
|
| 209 |
+
|
| 210 |
+
async with self._lock:
|
| 211 |
+
batch = self._pending
|
| 212 |
+
self._pending = {}
|
| 213 |
+
|
| 214 |
+
deltas: List[dict] = [
|
| 215 |
+
{"surface": s, "outcome": o, "gap_code": g, "n": n}
|
| 216 |
+
for (s, o, g), n in batch.items()
|
| 217 |
+
]
|
| 218 |
+
|
| 219 |
+
try:
|
| 220 |
+
import httpx
|
| 221 |
+
headers = {"Authorization": f"Bearer {WORKER_TOKEN}"} if WORKER_TOKEN else {}
|
| 222 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 223 |
+
r = await client.post(
|
| 224 |
+
f"{WORKER_URL}/rb/log",
|
| 225 |
+
json={"deltas": deltas},
|
| 226 |
+
headers=headers,
|
| 227 |
+
)
|
| 228 |
+
body = {}
|
| 229 |
+
try:
|
| 230 |
+
body = r.json()
|
| 231 |
+
except Exception:
|
| 232 |
+
pass
|
| 233 |
+
|
| 234 |
+
# The Worker answers 200 with logged=false when the D1 write
|
| 235 |
+
# itself failed, so the status code alone cannot tell a stored
|
| 236 |
+
# batch from a dropped one. Check the verdict, not the code.
|
| 237 |
+
if r.status_code < 400 and body.get("logged"):
|
| 238 |
+
self._record_attempt(
|
| 239 |
+
"ok", f"{body.get('accepted', len(deltas))} delta(s) stored"
|
| 240 |
+
)
|
| 241 |
+
return True
|
| 242 |
+
|
| 243 |
+
self._requeue(batch)
|
| 244 |
+
if r.status_code == 401:
|
| 245 |
+
self._record_attempt(
|
| 246 |
+
"rejected_401",
|
| 247 |
+
"CLOUDFLARE_WORKER_TOKEN does not match the Worker's ANALYTICS_TOKEN",
|
| 248 |
+
)
|
| 249 |
+
else:
|
| 250 |
+
self._record_attempt(
|
| 251 |
+
"refused",
|
| 252 |
+
f"HTTP {r.status_code} {body.get('warning', '')}",
|
| 253 |
+
)
|
| 254 |
+
return False
|
| 255 |
+
|
| 256 |
+
except Exception as exc:
|
| 257 |
+
self._requeue(batch)
|
| 258 |
+
self._record_attempt("unreachable", f"{type(exc).__name__}: {exc}")
|
| 259 |
+
return False
|
| 260 |
+
|
| 261 |
+
def _requeue(self, batch: Dict[_Key, int]) -> None:
|
| 262 |
+
for key, n in batch.items():
|
| 263 |
+
self._pending[key] = self._pending.get(key, 0) + n
|
| 264 |
+
|
| 265 |
+
async def _loop(self) -> None:
|
| 266 |
+
while True:
|
| 267 |
+
await asyncio.sleep(FLUSH_SECONDS)
|
| 268 |
+
try:
|
| 269 |
+
await self.flush()
|
| 270 |
+
except Exception as exc:
|
| 271 |
+
logger.warning("usage flush loop error: %s", exc)
|
| 272 |
+
|
| 273 |
+
def start(self) -> None:
|
| 274 |
+
"""Start the background flusher, if pushing is enabled at all."""
|
| 275 |
+
if not PUSH_ENABLED:
|
| 276 |
+
logger.info("Usage counting active in PULL mode β the Worker "
|
| 277 |
+
"collects counts on its cron; no outbound calls made.")
|
| 278 |
+
return
|
| 279 |
+
if self._task is not None:
|
| 280 |
+
return
|
| 281 |
+
try:
|
| 282 |
+
loop = asyncio.get_running_loop()
|
| 283 |
+
except RuntimeError:
|
| 284 |
+
return
|
| 285 |
+
# A strong reference is held on the instance: asyncio keeps only
|
| 286 |
+
# weak references to tasks, and a dropped task can be collected
|
| 287 |
+
# mid-flight, silently cancelling the flush.
|
| 288 |
+
self._task = loop.create_task(self._loop())
|
| 289 |
+
logger.info("Usage flusher started (%ss interval, worker=%s)",
|
| 290 |
+
FLUSH_SECONDS, bool(WORKER_URL))
|
| 291 |
+
|
| 292 |
+
async def stop(self) -> None:
|
| 293 |
+
"""Final flush on shutdown so the last minute of activity lands.
|
| 294 |
+
In pull mode there is nowhere to flush to, so this is a no-op and
|
| 295 |
+
up to 6 hours of counts can be lost on an unlucky restart β the
|
| 296 |
+
dashboard's "Send counts to Cloudflare now" button exists to make
|
| 297 |
+
that recoverable before a planned redeploy."""
|
| 298 |
+
if self._task:
|
| 299 |
+
self._task.cancel()
|
| 300 |
+
self._task = None
|
| 301 |
+
if PUSH_ENABLED:
|
| 302 |
+
await self.flush()
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
usage = UsageCounters()
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
# ββ FastAPI middleware ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 309 |
+
|
| 310 |
+
def install(app) -> None:
|
| 311 |
+
"""Wire counting into the app. No frontend change is required: the
|
| 312 |
+
tab a person used is implied by the endpoint their browser called,
|
| 313 |
+
which is why this survives the move to LibGuides and any future
|
| 314 |
+
frontend rewrite."""
|
| 315 |
+
from starlette.requests import Request
|
| 316 |
+
|
| 317 |
+
@app.middleware("http")
|
| 318 |
+
async def _count(request: Request, call_next):
|
| 319 |
+
surface = SURFACE_BY_PATH.get(request.url.path)
|
| 320 |
+
# Canary traffic exists to keep the Space awake and must never
|
| 321 |
+
# appear in usage figures β the same exclusion LibBee applies.
|
| 322 |
+
counted = surface and request.headers.get("X-ResearchBee-Canary") != "1"
|
| 323 |
+
|
| 324 |
+
try:
|
| 325 |
+
response = await call_next(request)
|
| 326 |
+
except Exception:
|
| 327 |
+
# An unhandled exception propagates out of call_next, so
|
| 328 |
+
# anything written after the await would never run. Without
|
| 329 |
+
# this branch the most important failures β the ones that
|
| 330 |
+
# crash a handler β would be the only ones missing from the
|
| 331 |
+
# dashboard.
|
| 332 |
+
if counted:
|
| 333 |
+
usage.record(surface, "error", "unhandled_exception")
|
| 334 |
+
raise
|
| 335 |
+
|
| 336 |
+
try:
|
| 337 |
+
if counted:
|
| 338 |
+
if response.status_code < 400:
|
| 339 |
+
# A handler that recorded its own miss has already
|
| 340 |
+
# counted this request; the flag stops it counting twice.
|
| 341 |
+
if not getattr(request.state, "usage_recorded", False):
|
| 342 |
+
usage.record(surface, "ok")
|
| 343 |
+
else:
|
| 344 |
+
usage.record(surface, "error")
|
| 345 |
+
except Exception:
|
| 346 |
+
pass
|
| 347 |
+
return response
|
| 348 |
+
|
| 349 |
+
@app.on_event("startup")
|
| 350 |
+
async def _start_usage():
|
| 351 |
+
usage.start()
|
| 352 |
+
|
| 353 |
+
@app.on_event("shutdown")
|
| 354 |
+
async def _stop_usage():
|
| 355 |
await usage.stop()
|
utils.py
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
-
"""Shared utilities for ResearchBee backend."""
|
| 2 |
-
from typing import Optional
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
def norm_issn(raw: str | None) -> Optional[str]:
|
| 6 |
-
"""
|
| 7 |
-
Normalise any ISSN-like string to XXXX-XXXX format.
|
| 8 |
-
Returns None if input is absent or not 8 digits after stripping.
|
| 9 |
-
"""
|
| 10 |
-
if not raw:
|
| 11 |
-
return None
|
| 12 |
-
digits = str(raw).strip().upper().replace(" ", "").replace("-", "")
|
| 13 |
-
return f"{digits[:4]}-{digits[4:]}" if len(digits) == 8 else None
|
|
|
|
| 1 |
+
"""Shared utilities for ResearchBee backend."""
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def norm_issn(raw: str | None) -> Optional[str]:
|
| 6 |
+
"""
|
| 7 |
+
Normalise any ISSN-like string to XXXX-XXXX format.
|
| 8 |
+
Returns None if input is absent or not 8 digits after stripping.
|
| 9 |
+
"""
|
| 10 |
+
if not raw:
|
| 11 |
+
return None
|
| 12 |
+
digits = str(raw).strip().upper().replace(" ", "").replace("-", "")
|
| 13 |
+
return f"{digits[:4]}-{digits[4:]}" if len(digits) == 8 else None
|