nikeshn commited on
Commit
7acb95f
Β·
verified Β·
1 Parent(s): f3e7ab2

Upload 31 files

Browse files
Files changed (29) hide show
  1. .env.example +17 -0
  2. .gitattributes +37 -37
  3. Dockerfile +7 -7
  4. README-DEPLOY.md +120 -91
  5. README.md +79 -65
  6. RELEASE_A_NOTES.md +46 -0
  7. ResearchBee_LibGuides.html +0 -0
  8. admin.py +730 -730
  9. app.py +226 -58
  10. asjc_classifier.py +216 -216
  11. asjc_eval.jsonl +20 -20
  12. build_centroids.py +130 -130
  13. doi_utils.py +92 -92
  14. harvest_openalex.py +353 -353
  15. journal_lookup.py +224 -224
  16. khazna_loader.py +136 -136
  17. ku_knowledge.py +369 -369
  18. oaworks.py +184 -56
  19. openalex.py +145 -145
  20. prompts.py +53 -72
  21. repositories.py +82 -13
  22. requirements-dev.txt +3 -0
  23. requirements.txt +12 -12
  24. researchbee_journals.py +606 -606
  25. scimago_index.py +298 -298
  26. style.css +28 -28
  27. train_asjc.py +329 -329
  28. usage.py +354 -354
  29. 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.4 β€” verified-sources release
2
 
3
- ## What changed, in one line
 
 
4
 
5
- Policy facts now come from databases, not from the language model. Where no
6
- source could be reached, the system says so instead of filling the gap.
7
 
8
- ---
 
 
 
 
 
 
 
 
 
9
 
10
- ## 1. HuggingFace Space β€” `researchbee-hf/`
 
11
 
12
- | File | Status |
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
- Upload all seven. `ku_knowledge.py`, `openalex.py`, `scimago_index.py`,
23
- `utils.py`, `Dockerfile`, and the SCImago CSV are unchanged β€” leave them.
24
 
25
- ### Before you deploy
 
 
 
26
 
27
- 1. **Set `KU_ROR` in `oaworks.py`.** Find it at https://ror.org (search
28
- "Khalifa University"). This matters more than it looks: Elsevier publishes a
29
- 12-month embargo for UK institutions and 24 months for everyone else, and
30
- the ROR lets OA.Works pick the right one.
31
- 2. Optionally set `KHAZNA_DATASET` and `HF_TOKEN` in Space secrets.
32
- 3. Check `GET /api/health` after deploy β€” it reports every source's status.
33
 
34
- Khazna lookups return `checked: false` until the harvest has run. That is
35
- correct behaviour, not a failure.
36
 
37
- ---
 
 
 
38
 
39
- ## 2. GitHub Pages β€” `researchbee-github/`
 
40
 
41
- | File | Status |
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
- Other JS files are untouched.
52
 
53
- ---
 
 
 
54
 
55
- ## 3. Khazna harvest β€” first run
 
56
 
57
  ```bash
58
- # See the deposit gap before automating anything
59
- python khazna_oai.py gap
 
 
 
 
60
 
61
- # Verify the parser against real records
62
- python khazna_oai.py inspect --set publications:withFiles --format mods -n 2 --raw
63
- python khazna_oai.py inspect --set openaire --format oai_dc -n 2 --raw
 
64
  ```
65
 
66
- Then:
67
- 1. Create an HF **Dataset** repo (default: `nikeshn/researchbee-data`)
68
- 2. Add `HF_TOKEN` as a GitHub repository secret
69
- 3. Actions β†’ "Harvest Khazna" β†’ **Run workflow** (first full run)
70
- 4. Nightly thereafter: Sunday full, Mon–Sat incremental
71
 
72
- Sunday is full because Khazna's OAI reports `deletedRecord: no` β€” incrementals
73
- can never learn that a record was withdrawn.
74
 
75
- ---
 
 
76
 
77
- ## 4. How to tell it is working
 
78
 
79
- **With a DOI** β€” green "Verified Β· OA.Works Permissions" badge, per-version
80
- rights, the publisher's required deposit statement with a copy button, a source
81
- panel with evidence links, and Khazna status.
82
 
83
- **Without a DOI** β€” amber "Unverified Β· journal-level estimate", all version
84
- blocks "Unclear", a prompt to enter a DOI. The backend forcibly downgrades any
85
- `policy_status: "Confirmed"` the model returns, so this cannot be bypassed.
 
 
 
 
 
86
 
87
- **Khazna unavailable** β€” "Khazna status not checked", explicitly distinguished
88
- from "Not yet in Khazna".
89
 
90
- Test DOIs from this build:
91
 
 
 
 
 
92
  ```
93
- 10.1080/19322909.2023.2221477 T&F β€” no embargo stated
94
- 10.1016/j.jclepro.2023.136775 Elsevier β€” 24 months (not 12; see below)
95
- 10.1007/s11192-023-04812-4 Springer β€” preprint immediate, AAM 12 months
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  ```
97
 
98
- ---
 
 
99
 
100
- ## 5. Things worth knowing
101
 
102
- **The Elsevier embargo trap.** OA.Works returns two permissions with identical
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
- **Absent is not zero.** T&F records carry no embargo field. The system reports
108
- "Not stated in the permission record" rather than "no embargo", because T&F's
109
- published policy is normally 12 months and a wrong permissive answer costs the
110
- library a takedown notice.
111
 
112
- **Publisher matcher.** Now covers imprints whose SCImago string never says the
113
- parent publisher β€” W.B. Saunders, Academic Press, Cell Press, BioMed Central,
114
- Palgrave, Blackwell, Informa, Dove Medical. Q1 APC badge coverage went from
115
- 45.8% to 51.1% (+497 journals). `AMBIGUOUS_ALIASES` prevents "Maximum Academic
116
- Press" from being mistaken for Elsevier.
117
 
118
- **Repository registry.** The model selects entries by `id` and writes the
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
- ## 6. Still open
 
 
 
 
 
 
126
 
127
- - `KU_ROR` is empty
128
- - Repository registry has blank fields awaiting your verification
129
- - Wiley, IEEE and ACS not yet validated against OA.Works (`test_publishers.py`)
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: Research Navigator
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
- # 🐝 Research Navigator
14
 
15
- **Publishing & Repository Assistant for Khalifa University Researchers**
16
 
17
- > *Find the right journal. Check your rights. Deposit to the right repository.*
 
 
 
18
 
19
- Research Navigator is an AI-powered publishing and open access assistant built by [Khalifa University Library](https://library.ku.ac.ae/lib). It helps KU researchers navigate journal selection, self-archiving rights, and research data management β€” in one place.
20
 
21
- ---
22
-
23
- ## ✨ Features
24
 
25
- ### πŸ“° Tab 1 β€” Journal Submission
26
- - 5 detailed journal cards + 10 quick-match suggestions (15 total per query)
27
- - Full Green OA breakdown per journal: Preprint / AAM / Version of Record
28
- - Ranking verified from 2025 journal data (32K+ journals) + OpenAlex enrichment
29
- - Scopus Sources direct links per journal for live metric verification
30
- - Khazna deposit guidance always included
31
 
32
- ### πŸ›‘ Tab 2 β€” License Checking
33
- - Version-specific self-archiving policy (3-version breakdown)
34
- - Embargo lengths, licence conditions, risk flags
35
- - Direct deposit guidance for Khazna
36
 
37
- ### πŸ—„ Tab 3 β€” Data Repository
38
- - 4–5 recommended repositories (domain-specific + generalist mix)
39
- - Khazna always first as KU institutional option
40
- - Metadata-first rule: register in Khazna even if data goes elsewhere
41
- - FAIR alignment, certification, cost, embargo support per repository
 
42
 
43
- ### πŸ›οΈ Khazna Integration
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
- ## πŸ—οΈ Architecture
51
 
52
- ```
53
- GitHub Pages (frontend) ──HTTPS──► HuggingFace Spaces (FastAPI)
54
- β”‚
55
- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
56
- β”‚ scimagojr_2025.csv β”‚
57
- β”‚ OpenAlex API (free) β”‚
58
- β”‚ OpenAI API (gpt-4o-mini) β”‚
59
- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 
 
 
60
  ```
61
 
62
- ---
 
 
63
 
64
- ## πŸš€ Deployment
 
 
 
65
 
66
- ### Backend β€” This Space
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
- ### Frontend β€” GitHub Pages
72
- 1. Update `HF_BASE` in `static/js/api.js` to your Space URL
73
- 2. Push to `gh-pages` branch of `ku-library/researchbee`
 
74
 
75
- ---
 
76
 
77
- ## πŸ“Š Data Sources
78
 
79
- | Source | Used for | Key needed? |
80
- |---|---|---|
81
- | SCImago CSV 2025 | Offline quartile lookup (private) | No β€” bundled |
82
- | OpenAlex API | Journal enrichment, h-index, citedness | No β€” free |
83
- | OpenAI API | All AI analysis | Yes |
84
 
85
- > Ranking metrics sourced from journal data (2025). Always verify policies on
86
- > [Open Policy Finder](https://openpolicyfinder.jisc.ac.uk) and repositories on
87
- > [re3data](https://www.re3data.org) before deposit.
 
88
 
89
- ---
90
 
91
- ## πŸ›οΈ About
 
 
 
 
 
 
 
92
 
93
- Built by [Khalifa University Library](https://library.ku.ac.ae/lib) as part of the Research Navigator suite.
 
94
 
95
- - πŸ“§ Library: library@ku.ac.ae
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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?' &middot; '+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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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?' &middot; '+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.4.0
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.4.1", lifespan=lifespan)
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.4.0",
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("_embargo_months") is not None,
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
- user_msg = (
1268
- f"{lang}Explain what this author should check regarding self-archiving. "
1269
- f"Return JSON only. You have NO policy database β€” do not state facts.\n\n"
1270
- f"Journal name: {li.journal_name}\nISSN: {issn or 'Not provided'}\n"
1271
- f"Publisher: {li.publisher or 'Not provided'}\nVersion: {li.manuscript_version}\n"
1272
- f"Funder: {li.funder or 'None'}\nIntended repository: {li.intended_repository}\n"
1273
- f"Intended licence: {li.intended_licence}\nNotes: {li.notes or 'None'}"
 
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
- if isinstance(parsed.get("journals"), list):
1294
- parsed["journals"] = await enrich_journals(parsed["journals"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 β€” if that
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
- enriched = []
1532
- for j in journals:
1533
- issn = j.get("issn")
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
- ── 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
 
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 _embargo_months(perm: dict) -> Optional[int]:
157
- """Raw embargo in months, or None when the record states none."""
158
- for k in ("embargo_months", "embargoMonths"):
159
- v = perm.get(k)
160
- if isinstance(v, (int, float)) and v > 0:
161
- return int(v)
162
- if isinstance(v, str) and v.strip().isdigit() and int(v) > 0:
163
- return int(v)
 
 
 
 
 
164
  return None
165
 
166
 
167
- def _embargo_of(perm: dict) -> tuple[str, Optional[int]]:
168
  """
169
- Returns (display_text, months_or_None).
170
 
171
- Some publishers (notably T&F) record no embargo at all. We say
172
- "not stated" rather than "none" β€” see the module docstring.
 
 
173
  """
174
- m = _embargo_months(perm)
175
- if m is None:
176
- return "Not stated in the permission record", None
177
- end = perm.get("embargo_end")
178
- txt = f"{m} months"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  if end:
180
- txt += f" (ends {end} for this article)"
181
- return txt, m
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
 
184
  def _permission_rank(perm: dict) -> tuple:
185
- """
186
- Sort key for choosing between competing permissions.
187
-
188
- CRITICAL: Elsevier publishes DIFFERENT embargoes for UK institutions
189
- (12 months) and everyone else (24 months), and both records carry the
190
- SAME score. Ranking by score alone can silently select the shorter UK
191
- embargo for a KU author. We therefore break score ties by the LONGER
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, _ = _embargo_of(perm)
 
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 perm.get("can_archive") else "No",
 
 
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(INSTITUTIONAL in l.lower()
290
- for p in chosen.values() for l in _clean_locations(p))
 
 
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": bool(top.get("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": _embargo_of(top)[1],
 
 
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": False,
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
- ok = green.get("_can_archive", False)
358
- emb = green.get("_embargo_months")
 
 
 
 
 
 
 
 
 
 
359
  best = green.get("_best_version", "")
360
- checks = []
361
- if green.get("policy_status") != "Confirmed":
 
362
  checks.append("Verify the policy at Open Policy Finder before depositing.")
363
- if ok and emb is None:
364
- checks.append("No embargo is stated in the record β€” confirm against the "
365
- "publisher's policy page before depositing immediately.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  return {
367
- "best_version_to_deposit": best or "Not specified",
368
- "best_timing": (f"After a {emb}-month embargo" if emb
369
- else ("Not stated β€” verify" if ok else "Not applicable")),
370
- "immediate_open_deposit_possible": "Yes" if (ok and emb is None) else "No",
371
- "embargoed_deposit_needed": "Yes" if emb else "No",
372
- "metadata_only_first": "Yes" if emb else "No",
 
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
- if green.get("_can_archive"):
 
 
 
381
  if green.get("_institutional_ok"):
382
- acts.append("Deposit the permitted version in the KU repository, Khazna.")
 
 
 
 
 
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("Self-archiving does not appear to be permitted β€” check "
390
- "whether a KU APC agreement covers Gold OA for this journal.")
 
 
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 β€” adding the file "
395
- "would close the gap.")
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 and repository specialist. Analyse the manuscript details and return a structured JSON response.
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": "OA and funder 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
- "repository_recommendation": {
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 β€” omit it entirely to save tokens.
57
- Always recommend depositing to Khazna (khazna.ku.ac.ae) for KU authors.
58
- Always recommend depositing to an appropriate institutional or subject repository."""
 
 
 
 
 
 
 
 
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. Set every version block to {"allowed": "Unclear", ...} unless the user
 
72
  themselves supplied the information, in which case attribute it to them.
73
- 4. NEVER invent an ISSN. If you were not given one, leave it empty.
74
- 5. Your job is to explain WHAT the author should check and WHERE, not to
75
  answer the policy question yourself.
76
- 6. Recommend entering the article DOI for a verified answer.
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 \u2014 confirm before depositing."
94
  }
95
  }
96
  ],
97
  "repository_recommendation": {
 
 
98
  "best_version_to_deposit": "Not determined",
99
- "best_timing": "Not determined",
100
- "immediate_open_deposit_possible": "No",
101
- "embargoed_deposit_needed": "No",
102
- "metadata_only_first": "No",
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 repositories,
141
- and you must NOT state factual repository attributes (max file size, licences,
142
- certification, cost, persistent identifier type) \u2014 those are supplied by the
143
- server from verified records and will overwrite anything you write.
 
144
 
145
  Your job is ONLY to:
146
  * understand the dataset,
147
- * choose the 4\u20136 most suitable registry entries by id, in rank order,
148
- * write a "fit_reason" for each explaining why it suits THIS dataset.
 
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
- "name": "",
166
- "type": "Institutional|Domain-specific|Generalist",
167
- "url": "",
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
- "primary_choice": "",
189
- "secondary_choice": "",
190
- "metadata_standard": "",
191
  "deposit_workflow": [],
192
  "manual_checks_required": []
193
  },
@@ -196,10 +174,13 @@ Return ONLY valid JSON:
196
  }
197
 
198
  Rules:
199
- - Recommend 4-5 repositories β€” prefer domain-specific, always include 1-2 generalist options (e.g. Zenodo, Figshare).
200
- - If institution is UW-Madison, note that Dryad and MINDS@UW are the primary institutional options and are free β€” these will be prepended by the server automatically.
201
- - If sensitive or PII data: recommend controlled-access repositories only.
202
- - If no funder mandate: still recommend open repositories for discoverability."""
 
 
 
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
- # Fields the LLM is NOT permitted to author. Anything it returns for these is
306
- # discarded and replaced from the registry.
307
- SERVER_OWNED = (
308
- "name", "type", "url", "scope", "data_types_accepted", "max_file_size",
309
- "persistent_identifier", "versioning", "embargo_support", "cost",
310
- "curation", "certification", "source_url", "verified", "is_khazna",
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
- Replace every server-owned field with the verified registry value.
330
 
331
- Anything the model invented β€” an unknown id, a fabricated file-size limit β€”
332
- is dropped. Only `fit_reason` and ranking survive from the model.
 
333
  """
334
  out: list[dict] = []
335
  seen: set[str] = set()
336
- for item in selected or []:
 
 
 
 
 
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
- merged = {k: v for k, v in item.items() if k not in SERVER_OWNED}
343
- merged.update(entry)
 
 
 
 
 
 
 
 
 
 
 
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