nikeshn commited on
Commit
0b82dd4
·
verified ·
1 Parent(s): 3685d4c

Upload 52 files

Browse files
.env.example CHANGED
@@ -27,3 +27,9 @@ USAGE_PUSH_ENABLED=false
27
  # Optional model configuration
28
  ASJC_LLM_MODEL=gpt-4.1-nano
29
  ANTHROPIC_API_KEY=
 
 
 
 
 
 
 
27
  # Optional model configuration
28
  ASJC_LLM_MODEL=gpt-4.1-nano
29
  ANTHROPIC_API_KEY=
30
+
31
+ # Optional re3data.org supplement for the data-repository finder (v1.8.3)
32
+ # Set to false to hide the "More repositories from re3data.org" list.
33
+ RE3DATA_ENABLED=true
34
+ # Upper bound on the re3data lookup; it runs alongside the model call.
35
+ RE3DATA_TIMEOUT_SECONDS=12
DEPLOYMENT-LINKS.md CHANGED
@@ -1,67 +1,67 @@
1
- # ResearchBee hyperlink update — 20 September 2026
2
-
3
- ## Upload the backend first
4
-
5
- 1. Keep a backup of your current Hugging Face Space and LibGuides HTML.
6
- 2. Extract `ResearchBee_HuggingFace_Backend.zip`. Upload **the contents of its `researchbee` folder** to the root of your existing Docker Space. Replace matching files. Do not upload the ZIP itself as the application.
7
- 3. Include the complete `tests/` directory, `trusted_links.py`, `journal_links_curated.json`, `journals.db`, `scimagojr_2025.csv`, requirements files, Dockerfile and all other supplied application files. The Docker build executes the Python tests. There is no new paid service or dependency.
8
- 4. Keep your existing Space secrets and configuration. Credentials belong only in Hugging Face Secrets; do not put them in the frontend. The existing OpenAlex integration and optional `OPENALEX_API_KEY` are reused.
9
- 5. Wait for the build. If the existing pinned packages cannot be resolved or a test fails, do not bypass the build gate. Check the build log before publishing the frontend. The preparation environment could not install these dependencies or run Docker.
10
- 6. Check `/api/health`, then try a journal search and the new “Find author guidelines” action. A publisher may reject automated access; an unavailable-guidance result with the retained homepage is expected in that case.
11
-
12
- ## Replace the LibGuides frontend
13
-
14
- 1. Open `ResearchBee_LibGuides.html` in a text editor and copy its complete contents into the existing LibGuides HTML/source block, replacing the old fragment.
15
- 2. The file remains one self-contained HTML fragment with embedded CSS and JavaScript. Do not add a second copy alongside the old one.
16
- 3. `RB_API_BASE` retains `https://nikeshn-researchbee.hf.space`. Change only that setting if your Space address changes.
17
- 4. Preview desktop and mobile layouts in LibGuides before publishing. Test English and Arabic, journal details, additional results, comparisons, article sharing, repository selection, and reset while an author-guidance request is pending.
18
- 5. Revert the HTML and backend together from your backup if necessary.
19
-
20
- ## What changed
21
-
22
- - Journal titles and prominent website/author-guidance actions in detailed cards, additional results and comparison views. Existing branding, mascot, institutional text, copyright, language options, feedback destination and workflows were retained from the separately supplied frontend.
23
- - Canonical database identities and ISSNs gate homepage metadata. OpenAlex responses must match the requested ISSN; a title search must match the complete normalized title. No LLM-supplied URLs are used by the new link controls.
24
- - New `POST /api/journal-links` accepts a canonical Scopus ID and/or ISSN, never an arbitrary URL. Publisher-page discovery runs only when the user clicks. It follows actual HTML anchors on an identity-matched publisher page and checks the discovered destination.
25
- - Official publisher domains must match the canonical publisher. HTTPS only, public DNS addresses, pinned connection IP with original TLS verification/Host, manual redirect validation, at most three requests per page, two pages per discovery, 512 KB per page, socket/page/overall discovery limits and four concurrent discovery operations.
26
- - Checked guidance is cached for 24 hours; unsuccessful guidance and metadata lookups for one hour. Caches are bounded to 512 records per process and reset on Space restart. Concurrent clicks for the same journal share the lookup. Publisher content can change after a check.
27
- - Evidence labels distinguish curated, checked, metadata-only and unavailable. Provider update/retrieval dates and curated review dates retain their meaning. A lookup attempt is not called a review. “Checked” means the official page/link was retrieved successfully, not that every instruction was reviewed by library staff.
28
- - Guidance is labelled journal-specific only when the checked page heading and journal path support that classification. Other discovered instructions are explicitly general publisher guidance.
29
- - Category quartiles are read from the supplied CSV using the matching SCImago source ID plus overlapping canonical ISSNs. The metric year is read from the CSV's `Total Docs. (2025)` header. Each category retains its own quartile; the best journal quartile is not substituted.
30
- - Category rankings use exact, unambiguous category-code matches from the supplied taxonomy; quartile badges use the journal's SCImago profile. Missing/ambiguous mappings show “Not available”. Scopus source links use Scopus IDs independently. No CiteScore quartiles are inferred or added.
31
- - Repository names and visit buttons use the existing curated registry. Primary/secondary deposit choices receive URLs from their resolved registry IDs, ignoring model-generated URLs. Existing registry source and review information is retained.
32
- - Late discovery responses cannot change results after reset or a newer search. Request failures preserve the homepage and allow a retry.
33
-
34
- ## Evidence limitations and maintenance
35
-
36
- There was no curated author-guidelines catalogue in the upload. `journal_links_curated.json` therefore starts empty; no remembered or guessed publisher paths have been added. Staff can add records keyed by canonical Scopus source ID with `issns`, `url`, `source`, `scope` (`journal-specific` or `general-publisher`), and an actual `reviewed_at` date. The ISSNs and official publisher domains are validated. Do not populate dates without a real review. Extend the publisher-domain allowlist only after verifying publisher ownership.
37
-
38
- The supplied taxonomy has duplicate names under different codes, and some CSV categories have no code match. Those category names remain visible with their original quartile and profile link, but their category-ranking link says “Not available”. This is deliberate: resolving them requires an authoritative SCImago category crosswalk. An attempt to retrieve the live SCImago rankings page was blocked with HTTP 403 in this environment.
39
-
40
- Some publishers require JavaScript, cookies or authentication, use another platform/domain, omit a discoverable author link, or block automated requests. The resolver does not guess a path or bypass access controls. It retains the metadata homepage and reports unavailable guidance. A homepage labelled metadata-only has not been tested for reachability.
41
-
42
- ## Validation performed
43
-
44
- - 17 offline Python link-resolver tests passed using the actual bundled database and CSV plus mocked upstream responses: canonical identity conflicts, metadata identity, source dates, missing evidence, category-specific quartiles, separate identifiers, real relative-anchor extraction, general guidance labels, successful/negative/expired/bounded caches, concurrent duplicate requests, failed requests, unsafe URLs/private DNS/redirects, and curated repository selection.
45
- - 12 Node VM frontend behavior checks passed against the actual embedded functions: safe URL rendering, clickable titles, evidence labels, distinct category quartiles, reset/session invalidation, successful result updates and failed-request behavior.
46
- - 7 existing dependency-free frontend/repository regression checks passed.
47
- - Python compilation and embedded JavaScript syntax checks passed.
48
- - Full original tests directory retained. Added two FastAPI endpoint integration tests for identity-only input and rate limiting; these are included in the Docker pytest gate but could not be executed here.
49
-
50
- ## Not verified here
51
-
52
- The environment could not install the pinned Python requirements. Therefore the full FastAPI/pytest suite, API server startup and Docker build were not run. Playwright was present but no browser executable was installed, so no visual/browser or real LibGuides test was completed. Node VM checks are not browser tests. Live OpenAlex, publisher discovery, credential-dependent services and production deployment were not verified. The release has not been deployed.
53
-
54
- Reproduce checks in a configured development environment:
55
-
56
- ```bash
57
- python -m pip install -r requirements-dev.txt
58
- python -m compileall -q .
59
- python -m pytest -q
60
- node tests/test_frontend_links.cjs
61
- ```
62
-
63
- The standalone offline link tests need only the Python standard library:
64
-
65
- ```bash
66
- python -m unittest discover -s tests -p test_trusted_links.py -v
67
- ```
 
1
+ # ResearchBee hyperlink update — 20 September 2026
2
+
3
+ ## Upload the backend first
4
+
5
+ 1. Keep a backup of your current Hugging Face Space and LibGuides HTML.
6
+ 2. Extract `ResearchBee_HuggingFace_Backend.zip`. Upload **the contents of its `researchbee` folder** to the root of your existing Docker Space. Replace matching files. Do not upload the ZIP itself as the application.
7
+ 3. Include the complete `tests/` directory, `trusted_links.py`, `journal_links_curated.json`, `journals.db`, `scimagojr_2025.csv`, requirements files, Dockerfile and all other supplied application files. The Docker build executes the Python tests. There is no new paid service or dependency.
8
+ 4. Keep your existing Space secrets and configuration. Credentials belong only in Hugging Face Secrets; do not put them in the frontend. The existing OpenAlex integration and optional `OPENALEX_API_KEY` are reused.
9
+ 5. Wait for the build. If the existing pinned packages cannot be resolved or a test fails, do not bypass the build gate. Check the build log before publishing the frontend. The preparation environment could not install these dependencies or run Docker.
10
+ 6. Check `/api/health`, then try a journal search and the new “Find author guidelines” action. A publisher may reject automated access; an unavailable-guidance result with the retained homepage is expected in that case.
11
+
12
+ ## Replace the LibGuides frontend
13
+
14
+ 1. Open `ResearchBee_LibGuides.html` in a text editor and copy its complete contents into the existing LibGuides HTML/source block, replacing the old fragment.
15
+ 2. The file remains one self-contained HTML fragment with embedded CSS and JavaScript. Do not add a second copy alongside the old one.
16
+ 3. `RB_API_BASE` retains `https://nikeshn-researchbee.hf.space`. Change only that setting if your Space address changes.
17
+ 4. Preview desktop and mobile layouts in LibGuides before publishing. Test English and Arabic, journal details, additional results, comparisons, article sharing, repository selection, and reset while an author-guidance request is pending.
18
+ 5. Revert the HTML and backend together from your backup if necessary.
19
+
20
+ ## What changed
21
+
22
+ - Journal titles and prominent website/author-guidance actions in detailed cards, additional results and comparison views. Existing branding, mascot, institutional text, copyright, language options, feedback destination and workflows were retained from the separately supplied frontend.
23
+ - Canonical database identities and ISSNs gate homepage metadata. OpenAlex responses must match the requested ISSN; a title search must match the complete normalized title. No LLM-supplied URLs are used by the new link controls.
24
+ - New `POST /api/journal-links` accepts a canonical Scopus ID and/or ISSN, never an arbitrary URL. Publisher-page discovery runs only when the user clicks. It follows actual HTML anchors on an identity-matched publisher page and checks the discovered destination.
25
+ - Official publisher domains must match the canonical publisher. HTTPS only, public DNS addresses, pinned connection IP with original TLS verification/Host, manual redirect validation, at most three requests per page, two pages per discovery, 512 KB per page, socket/page/overall discovery limits and four concurrent discovery operations.
26
+ - Checked guidance is cached for 24 hours; unsuccessful guidance and metadata lookups for one hour. Caches are bounded to 512 records per process and reset on Space restart. Concurrent clicks for the same journal share the lookup. Publisher content can change after a check.
27
+ - Evidence labels distinguish curated, checked, metadata-only and unavailable. Provider update/retrieval dates and curated review dates retain their meaning. A lookup attempt is not called a review. “Checked” means the official page/link was retrieved successfully, not that every instruction was reviewed by library staff.
28
+ - Guidance is labelled journal-specific only when the checked page heading and journal path support that classification. Other discovered instructions are explicitly general publisher guidance.
29
+ - Category quartiles are read from the supplied CSV using the matching SCImago source ID plus overlapping canonical ISSNs. The metric year is read from the CSV's `Total Docs. (2025)` header. Each category retains its own quartile; the best journal quartile is not substituted.
30
+ - Category rankings use exact, unambiguous category-code matches from the supplied taxonomy; quartile badges use the journal's SCImago profile. Missing/ambiguous mappings show “Not available”. Scopus source links use Scopus IDs independently. No CiteScore quartiles are inferred or added.
31
+ - Repository names and visit buttons use the existing curated registry. Primary/secondary deposit choices receive URLs from their resolved registry IDs, ignoring model-generated URLs. Existing registry source and review information is retained.
32
+ - Late discovery responses cannot change results after reset or a newer search. Request failures preserve the homepage and allow a retry.
33
+
34
+ ## Evidence limitations and maintenance
35
+
36
+ There was no curated author-guidelines catalogue in the upload. `journal_links_curated.json` therefore starts empty; no remembered or guessed publisher paths have been added. Staff can add records keyed by canonical Scopus source ID with `issns`, `url`, `source`, `scope` (`journal-specific` or `general-publisher`), and an actual `reviewed_at` date. The ISSNs and official publisher domains are validated. Do not populate dates without a real review. Extend the publisher-domain allowlist only after verifying publisher ownership.
37
+
38
+ The supplied taxonomy has duplicate names under different codes, and some CSV categories have no code match. Those category names remain visible with their original quartile and profile link, but their category-ranking link says “Not available”. This is deliberate: resolving them requires an authoritative SCImago category crosswalk. An attempt to retrieve the live SCImago rankings page was blocked with HTTP 403 in this environment.
39
+
40
+ Some publishers require JavaScript, cookies or authentication, use another platform/domain, omit a discoverable author link, or block automated requests. The resolver does not guess a path or bypass access controls. It retains the metadata homepage and reports unavailable guidance. A homepage labelled metadata-only has not been tested for reachability.
41
+
42
+ ## Validation performed
43
+
44
+ - 17 offline Python link-resolver tests passed using the actual bundled database and CSV plus mocked upstream responses: canonical identity conflicts, metadata identity, source dates, missing evidence, category-specific quartiles, separate identifiers, real relative-anchor extraction, general guidance labels, successful/negative/expired/bounded caches, concurrent duplicate requests, failed requests, unsafe URLs/private DNS/redirects, and curated repository selection.
45
+ - 12 Node VM frontend behavior checks passed against the actual embedded functions: safe URL rendering, clickable titles, evidence labels, distinct category quartiles, reset/session invalidation, successful result updates and failed-request behavior.
46
+ - 7 existing dependency-free frontend/repository regression checks passed.
47
+ - Python compilation and embedded JavaScript syntax checks passed.
48
+ - Full original tests directory retained. Added two FastAPI endpoint integration tests for identity-only input and rate limiting; these are included in the Docker pytest gate but could not be executed here.
49
+
50
+ ## Not verified here
51
+
52
+ The environment could not install the pinned Python requirements. Therefore the full FastAPI/pytest suite, API server startup and Docker build were not run. Playwright was present but no browser executable was installed, so no visual/browser or real LibGuides test was completed. Node VM checks are not browser tests. Live OpenAlex, publisher discovery, credential-dependent services and production deployment were not verified. The release has not been deployed.
53
+
54
+ Reproduce checks in a configured development environment:
55
+
56
+ ```bash
57
+ python -m pip install -r requirements-dev.txt
58
+ python -m compileall -q .
59
+ python -m pytest -q
60
+ node tests/test_frontend_links.cjs
61
+ ```
62
+
63
+ The standalone offline link tests need only the Python standard library:
64
+
65
+ ```bash
66
+ python -m unittest discover -s tests -p test_trusted_links.py -v
67
+ ```
README-DEPLOY.md CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  # ResearchBee 1.8.2 — upload instructions
2
 
3
  Prepared 10 September 2026 from your uploaded HF backend and the separately uploaded LibGuides text. Your original uploads were preserved.
@@ -39,6 +43,8 @@ The API address remains `https://nikeshn-researchbee.hf.space`. No frontend API
39
  | `CLOUDFLARE_WORKER_URL`, `CLOUDFLARE_WORKER_TOKEN` | Keep existing values if using the external usage dashboard. Credentials stay on the server. |
40
  | `USAGE_PUSH_ENABLED` | Preserve existing setting; use `true` for periodic usage pushes. |
41
  | `OPENALEX_API_KEY` | Optional supported OpenAlex credential. Configure it if required by your OpenAlex access plan. |
 
 
42
  | `RESEARCHBEE_MODEL`, `RESEARCHBEE_MODEL_LIGHT` | Defaults remain `gpt-4.1-mini` and `gpt-4.1-nano`. Use models your account supports. |
43
 
44
  No new secret is mandatory for the code repairs themselves. Existing features still depend on their corresponding valid credentials and services.
 
1
+ # ResearchBee upload instructions (current: 1.8.3 / interface v1.9.3)
2
+
3
+ > **1.8.3 quick path.** Upload the extracted backend files (including the new `re3data.py`, `tests/test_re3data.py`, `tests/test_subject_nearest.py`, `tests/test_frontend_re3data.cjs` and `tests/fixtures/`) to the Space, wait for the build, and confirm `/api/health` shows `version: "1.8.3"` and a `sources.re3data` entry. Then replace the LibGuides fragment with `ResearchBee_LibGuides.html` and confirm the footer shows **Interface v1.9.3**. Post-deploy checks: browse by subject "physics" (journals appear with an amber "nearest verified subject area" notice); run a repository search with discipline "Materials science" (a "More repositories from re3data.org" section appears below the curated cards). No new secret is required. The sections below are the 1.8.2 guide and still apply.
4
+
5
  # ResearchBee 1.8.2 — upload instructions
6
 
7
  Prepared 10 September 2026 from your uploaded HF backend and the separately uploaded LibGuides text. Your original uploads were preserved.
 
43
  | `CLOUDFLARE_WORKER_URL`, `CLOUDFLARE_WORKER_TOKEN` | Keep existing values if using the external usage dashboard. Credentials stay on the server. |
44
  | `USAGE_PUSH_ENABLED` | Preserve existing setting; use `true` for periodic usage pushes. |
45
  | `OPENALEX_API_KEY` | Optional supported OpenAlex credential. Configure it if required by your OpenAlex access plan. |
46
+ | `RE3DATA_ENABLED` | Optional (1.8.3). Default `true`. Set `false` to hide the re3data.org supplementary list. |
47
+ | `RE3DATA_TIMEOUT_SECONDS` | Optional (1.8.3). Default `12`. Upper bound on the re3data lookup. |
48
  | `RESEARCHBEE_MODEL`, `RESEARCHBEE_MODEL_LIGHT` | Defaults remain `gpt-4.1-mini` and `gpt-4.1-nano`. Use models your account supports. |
49
 
50
  No new secret is mandatory for the code repairs themselves. Existing features still depend on their corresponding valid credentials and services.
README.md CHANGED
@@ -1,66 +1,71 @@
1
- ---
2
- title: ResearchBee
3
- emoji: 🐝
4
- colorFrom: indigo
5
- colorTo: purple
6
- sdk: docker
7
- app_port: 7860
8
- pinned: false
9
- license: mit
10
- short_description: Journal selection, Open Access and research data assistant
11
- ---
12
-
13
- # ResearchBee — hyperlink update
14
-
15
- See **DEPLOYMENT-LINKS.md** for this update, upload instructions, executed checks and untested limitations. The following notes describe the supplied base release.
16
-
17
- ## Base release 1.8.2
18
-
19
- Publishing, Open Access and research data guidance for Khalifa University researchers.
20
-
21
- This release repairs the supplied backend and uses the separately uploaded LibGuides fragment as the frontend baseline. It retains the existing HF endpoint and `journal_discovery_v2` schema.
22
-
23
- ## Included functions
24
-
25
- - Journal discovery by manuscript or subject, verified identities and subject quartiles, preferred/excluded titles, comparisons and exports.
26
- - On-demand journal policies and DOI-based article-sharing checks with version, destination, licence, funder and embargo context.
27
- - Dataset repository selection from a curated registry, with deterministic constraint checks.
28
- - Source-aware English/Arabic chat, tool shortcuts, related literature and cover-letter drafts.
29
- - Health reporting, authenticated Khazna refresh and administration/usage monitoring.
30
-
31
- ## Main repairs
32
-
33
- - Repaired 7,721 missing subject-quartile values in 7,096 existing journal records using the bundled 2025 CSV. Remaining unknowns stay unknown.
34
- - Retained eligible preferred journals through both shortlist limits; removed inflated fallback match scores.
35
- - Sharing advice evaluates complete policy options. Permission, embargo timing and destination conditions cannot be borrowed from incompatible options.
36
- - Repository recommendations enforce declared capabilities and sensitive-data restrictions. Khazna dataset advice is metadata registration, with file-hosting capabilities requiring confirmation.
37
- - Fixed chat rendering, question chips, history limits, SSE interruptions, autocomplete identity, embargo controls, CSV safety and long-abstract cover letters.
38
- - Added Arabic knowledge matching, interface translations, focus/keyboard improvements and source-review warnings.
39
- - Added bounded requests, paid-route quotas/concurrency, safer admin sessions, server-only usage credentials, redacted failure logging and lifecycle repairs.
40
- - OpenAlex receives broad validated taxonomy terms for manuscript evidence searches, not the unpublished title or abstract. It still receives journal identifiers and public literature queries.
41
- - Disabled the unsupported journal-level Altmetric score and unrepresentative percentile calculation.
42
-
43
- ## Deploy
44
-
45
- See **README-DEPLOY.md**. Upload the complete extracted release to the existing Docker Space, including `journals.db`, new helper modules and `tests/`. Then replace the entire LibGuides fragment with `ResearchBee_LibGuides.html`.
46
-
47
- The Docker build installs pinned dependencies, runs `pip check`, compiles Python and runs the full Python test suite before creating the runtime image. Production runs as a non-root user.
48
-
49
- ## Validation
50
-
51
- See **VALIDATION_1.8.2.md** for executed checks and limitations. Local verification used Python 3.12 and Node 24; the container targets Python 3.11. The Docker build and real FastAPI HTTP smoke suite could not be executed in the preparation environment. Live credentials, external service availability and rendered LibGuides browser behavior require the post-upload checks.
52
-
53
- ```bash
54
- python -m pip install -r requirements-dev.txt
55
- python -m pytest -q
56
- python tests/run_offline_release_checks.py
57
- ```
58
-
59
- ## Evidence boundaries
60
-
61
- Journal identities and metrics come from the supplied local database and 2025 CSV. This release does not refresh them from current Scopus/SCImago sources. Fit scores support comparison; they do not estimate acceptance probability. Current APCs, title-level KU eligibility and unstructured policy conditions need authoritative confirmation.
62
-
63
- Khazna lookup reports a loaded snapshot. Its source harvest date is absent from the supplied snapshot format, so a missing DOI is not proof of current repository absence.
64
-
65
- Questions: library@ku.ac.ae; Khazna: khazna@ku.ac.ae.
66
-
 
 
 
 
 
 
1
+ ---
2
+ title: ResearchBee
3
+ emoji: 🐝
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ short_description: Journal selection, Open Access and research data assistant
11
+ ---
12
+
13
+ # ResearchBee 1.8.3 (interface v1.9.3)
14
+
15
+ **What's new:** nearest-subject matching for journal browse (e.g. "physics" → Physics and Astronomy, disclosed to the user) and a clearly labelled re3data.org supplement in the data-repository finder. See **RELEASE_1.8.3.md** for details, validation and post-deploy checks. Deploy as described in README-DEPLOY.md; the new optional settings are `RE3DATA_ENABLED` and `RE3DATA_TIMEOUT_SECONDS`.
16
+
17
+ ## Earlier: hyperlink update
18
+
19
+ See **DEPLOYMENT-LINKS.md** for this update, upload instructions, executed checks and untested limitations. The following notes describe the supplied base release.
20
+
21
+ ## Base release 1.8.2
22
+
23
+ Publishing, Open Access and research data guidance for Khalifa University researchers.
24
+
25
+ This release repairs the supplied backend and uses the separately uploaded LibGuides fragment as the frontend baseline. It retains the existing HF endpoint and `journal_discovery_v2` schema.
26
+
27
+ ## Included functions
28
+
29
+ - Journal discovery by manuscript or subject, verified identities and subject quartiles, preferred/excluded titles, comparisons and exports.
30
+ - On-demand journal policies and DOI-based article-sharing checks with version, destination, licence, funder and embargo context.
31
+ - Dataset repository selection from a curated registry, with deterministic constraint checks.
32
+ - Source-aware English/Arabic chat, tool shortcuts, related literature and cover-letter drafts.
33
+ - Health reporting, authenticated Khazna refresh and administration/usage monitoring.
34
+
35
+ ## Main repairs
36
+
37
+ - Repaired 7,721 missing subject-quartile values in 7,096 existing journal records using the bundled 2025 CSV. Remaining unknowns stay unknown.
38
+ - Retained eligible preferred journals through both shortlist limits; removed inflated fallback match scores.
39
+ - Sharing advice evaluates complete policy options. Permission, embargo timing and destination conditions cannot be borrowed from incompatible options.
40
+ - Repository recommendations enforce declared capabilities and sensitive-data restrictions. Khazna dataset advice is metadata registration, with file-hosting capabilities requiring confirmation.
41
+ - Fixed chat rendering, question chips, history limits, SSE interruptions, autocomplete identity, embargo controls, CSV safety and long-abstract cover letters.
42
+ - Added Arabic knowledge matching, interface translations, focus/keyboard improvements and source-review warnings.
43
+ - Added bounded requests, paid-route quotas/concurrency, safer admin sessions, server-only usage credentials, redacted failure logging and lifecycle repairs.
44
+ - OpenAlex receives broad validated taxonomy terms for manuscript evidence searches, not the unpublished title or abstract. It still receives journal identifiers and public literature queries.
45
+ - Disabled the unsupported journal-level Altmetric score and unrepresentative percentile calculation.
46
+
47
+ ## Deploy
48
+
49
+ See **README-DEPLOY.md**. Upload the complete extracted release to the existing Docker Space, including `journals.db`, new helper modules and `tests/`. Then replace the entire LibGuides fragment with `ResearchBee_LibGuides.html`.
50
+
51
+ The Docker build installs pinned dependencies, runs `pip check`, compiles Python and runs the full Python test suite before creating the runtime image. Production runs as a non-root user.
52
+
53
+ ## Validation
54
+
55
+ See **VALIDATION_1.8.2.md** for executed checks and limitations. Local verification used Python 3.12 and Node 24; the container targets Python 3.11. The Docker build and real FastAPI HTTP smoke suite could not be executed in the preparation environment. Live credentials, external service availability and rendered LibGuides browser behavior require the post-upload checks.
56
+
57
+ ```bash
58
+ python -m pip install -r requirements-dev.txt
59
+ python -m pytest -q
60
+ node tests/test_frontend_links.cjs
61
+ node tests/test_frontend_re3data.cjs
62
+ ```
63
+
64
+ ## Evidence boundaries
65
+
66
+ Journal identities and metrics come from the supplied local database and 2025 CSV. This release does not refresh them from current Scopus/SCImago sources. Fit scores support comparison; they do not estimate acceptance probability. Current APCs, title-level KU eligibility and unstructured policy conditions need authoritative confirmation.
67
+
68
+ Khazna lookup reports a loaded snapshot. Its source harvest date is absent from the supplied snapshot format, so a missing DOI is not proof of current repository absence.
69
+
70
+ Questions: library@ku.ac.ae; Khazna: khazna@ku.ac.ae.
71
+
RELEASE_1.8.3.md ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ResearchBee 1.8.3 — change record (30 October 2026)
2
+
3
+ Backend `1.8.3`, LibGuides interface `v1.9.3`. Earlier features are unchanged unless a change is listed here.
4
+
5
+ ## 1. Journal browse by subject: nearest-subject matching
6
+
7
+ **Problem.** A subject that was not a literal SCImago/ASJC name (for example "physics", "maths", "biology", "machine learning") depended entirely on the LLM classifier. When the classifier was unavailable, or answered `not_found` for a broad term, the user saw no journals.
8
+
9
+ **Change.** `/api/browse-subject` now resolves subjects in this order:
10
+
11
+ 1. Exact vocabulary term, as before.
12
+ 2. Conservative spelling repair, as before.
13
+ 3. **New:** deterministic nearest match in `scimago_index.nearest_subject_term()`:
14
+ - a reviewed alias table (about 200 common discipline names, for example maths → Mathematics, machine learning → Artificial Intelligence, petroleum engineering → Energy Engineering and Power Technology, criminology → Sociology and Political Science);
15
+ - whole-word containment, for example physics → area "Physics and Astronomy", or electrical engineering → "Electrical and Electronic Engineering". Parenthetical qualifiers are ignored, and generic words such as "science", "engineering" or "learning" never decide a match on their own.
16
+ 4. LLM classifier (closed vocabulary), as before.
17
+ 5. **New:** if the classifier fails or returns no mapping, a last-resort partial match on a distinctive word (for example "quantum computing networks" stays unmatched, while "geology rocks" maps to Geology).
18
+
19
+ Every nearest or synonym match is disclosed to the user, for example: *SCImago and Scopus have no exact subject category for "physics". ResearchBee selected the nearest verified subject area "Physics And Astronomy".* Up to four verified alternative categories are offered as one-click chips. The disclosure note is highlighted in amber in the interface.
20
+
21
+ Every alias target is validated against the bundled vocabulary when it is used, and a test fails if any target disappears. Meaningless input ("xyzzy") still returns no journals. Deterministic matches avoid an LLM call, which reduces cost and latency.
22
+
23
+ Also fixed: the spelling-correction notice was shown twice.
24
+
25
+ ## 2. Data repository finder: re3data.org supplement
26
+
27
+ The curated KU Library registry stays the only source of recommendations, deposit strategy and Khazna guidance. New module `re3data.py` adds a separate list: **More repositories from re3data.org**, labelled *Not reviewed by KU Library*.
28
+
29
+ - Runs in parallel with the model call, so it adds no latency in normal operation. It is bounded by `RE3DATA_TIMEOUT_SECONDS` (default 12).
30
+ - **Privacy:** only the *Discipline / domain* field is sent to re3data. The dataset title, description and notes are never sent.
31
+ - Uses the re3data beta search API and v1 repository records, with namespace-agnostic XML parsing that refuses DTDs and is capped at 2 MB.
32
+ - Excludes repositories that are already in the curated registry, closed (end date), closed to uploads, or purely institutional. These are usually limited to their own staff.
33
+ - For datasets marked sensitive or personal, only repositories that list restricted, embargoed or closed access are shown, with the standard approval warning.
34
+ - Ranks by subject/keyword/name evidence, then DOI, certification, open upload and versioning. Shows up to five.
35
+ - Shows subjects, PID system, upload and access type, versioning, certificates, country, the repository link, the re3data record link and the record's own last-update date.
36
+ - The list opens automatically when the curated registry produced fewer than two candidates. Otherwise it is collapsed.
37
+ - Failures are isolated. An unreachable re3data service shows a short note and a "Search re3data.org yourself" link, and the curated results are unaffected.
38
+ - Caches: 24 hours for searches, 7 days for records, 10 minutes for failures. All are bounded.
39
+ - `/api/health` reports `sources.re3data`. Set `RE3DATA_ENABLED=false` to switch the list off.
40
+
41
+ Also fixed: the curated cards' "Verify on re3data/FAIRsharing" links now URL-encode repository names.
42
+
43
+ ## 3. Frontend
44
+
45
+ - Based on the live LibGuides fragment supplied on 30 October 2026 (KU styling). Its JavaScript was identical to the 1.8.2 package copy.
46
+ - Adds amber styling for nearest/synonym subject notes, the re3data section (desktop and mobile), and Arabic labels for the new strings.
47
+ - The footer reads **Interface v1.9.3**.
48
+
49
+ ## Validation
50
+
51
+ - Python 3.11 (Docker target) and 3.13 with pinned requirements: `pip check` clean, `compileall` clean, **78 tests passed**. These are the 37 existing tests plus 27 subject tests and 14 re3data tests.
52
+ - Node: 12 existing and 16 new frontend checks passed. The embedded script passes a syntax check.
53
+ - Real-browser end-to-end test (Chromium/Playwright) of the live fragment against the real backend under uvicorn, with only the LLM and the re3data transport mocked:
54
+ - "physics" returns 15 verified journals with the nearest-area notice and alternative chips, and an alternative chip runs an exact search;
55
+ - the repository flow shows the curated cards, then the re3data section with safe links;
56
+ - no horizontal overflow at 390 px;
57
+ - no JavaScript errors.
58
+
59
+ ## Not verified here
60
+
61
+ - The live re3data API was not reachable from the preparation environment (egress blocked). Parsing was built against re3data's published schema and tested with fixtures in that shape. After deployment, run one repository search and check that the re3data section shows real entries, or that `/api/health → sources.re3data.last_error` is empty.
62
+ - The live OpenAI classifier and the Docker build on Hugging Face were not executed. The same pytest gate was run locally on Python 3.11.
ResearchBee_LibGuides.html CHANGED
The diff for this file is too large to render. See raw diff
 
SHA256SUMS.json CHANGED
@@ -1,62 +1,73 @@
1
- {
2
- ".dockerignore": "acbfa007f9cc614a581b5517709de7fb6df4a1836bc7c0c7bee7b96d020f3b83",
3
- ".env.example": "3b015ac559145433fffa041d0de37bc0e0e1d823dd8b7ca08284384232c7d6c1",
4
- ".gitattributes": "4d72e9b97f1cb0fd76258dec00ff2a9ac929fafab43ac1e68da5d4ab82149622",
5
- "DEPLOYMENT-LINKS.md": "57ee8ab82d4bf157899b7fb265938ca568860b4b8160423edb14740239e62121",
6
- "Dockerfile": "a5289268fe287dd5677b37ce60017563f7294729bed078c935cfce3fc010d280",
7
- "README-DEPLOY.md": "c0906dc9e26fbe235109e0b70bc3c12aed33e4f3e50fff30fc7987d5b758da2c",
8
- "README.md": "470e323af6c7c2ca4d487d1ce169817ebc5585fc322b7f3a384a22cc99d2abb0",
9
- "RELEASE_1.7.0.md": "334d4cb6215ff7ab540c539d3c3f8d2830641ced95c4f9325fdd86c78b7488ce",
10
- "RELEASE_1.8.0.md": "4d85c1711c33cd314c799d27e59795b62fe9104656d79cd76bba6af538b9ad1e",
11
- "RELEASE_1.8.1.md": "500ba91d0737c6198a453de2bd539827b019185c34d68d32d325a2dc5e106fa4",
12
- "RELEASE_1.8.2.md": "b006bed1643a39a8f79930ffb682c37c64ba00a3752b9a056993791153810f88",
13
- "RELEASE_A_NOTES.md": "2c9d4dd7b8e787ada145b2393578c6b3499370fb4be2a89100bd555ed8b96a42",
14
- "RELEASE_OPF_WEB_1.5.2.md": "b4cc66479031664e35026fdbb57ec0a83468e1d54100ac022100b2849df67b77",
15
- "ResearchBee_LibGuides.html": "6d2946b8322a5c59c8d321d764cfb000196d5d5fe763380f8bf4c21feb0d1dd8",
16
- "VALIDATION_1.8.2.md": "2c5ae04d43ebe3e3867bd5fbb4bdf303cebf03a7dc85dddc0079844b3ec82f41",
17
- "admin.py": "7db70eb130f8bc4d9e98f566ce86315fd2dfa4cbe90e3415dba037aa13d28421",
18
- "app.py": "9bd3acbd331080d97fef89314df0d298bcd44f2e6e50c1be3adf48ebf5e0b1f4",
19
- "asjc_classifier.py": "c689f9d1fcd3ac15b4a0bd711153acbc9cb8780caed220f0c5d6afcde9af6fa6",
20
- "asjc_eval.jsonl": "cc762d1d81532574a2270b98758324dc7c251b1d22a58604045ca5fa02a1b684",
21
- "build_centroids.py": "48b3064a7b261195824fdedf84708ebb6e36954b9160d4b1722235d548e93e42",
22
- "chat_knowledge.py": "d8b69d510ae847bb46f2c054a1458df7fe4a8db237dfb94ae515cfd4dff414ad",
23
- "doi_utils.py": "7026a3913998b94aa4822f7cbbe9bb779ce52880f6e5a180959b37a19de9f8f9",
24
- "harvest_openalex.py": "3c3cedd28c0892a10126249d892cd27275131d5295669d10d201ebae9ea4da3d",
25
- "journal_discovery.py": "b69d282411eb4fecd3bcdb354cc34ac27f705b5b1d71ae600372d75a4672c12d",
26
- "journal_links_curated.json": "ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356",
27
- "journal_lookup.py": "4d7e1638b48cfd1c908b217c3e4b8bf145932a9f34d3cb762092a99e4a817aa0",
28
- "journals.db": "6553cefa4515cbeea4631fcd5f59a4b8bdb756fe83701788e8a11fb9298b2404",
29
- "khazna_loader.py": "3e944cbfa116647ff548015b37e083134da9aa2aafbda0e957be6c31ea7ac9c7",
30
- "knowledge_base.json": "1838c1d1fd5456c16308ebfaba237b8516aa01df5901a83e7290bb73b55b5bc6",
31
- "ku_knowledge.py": "b54c5acd3c26783e5b44872897810d04ae0257df367b9fde62e8b6bb6e5f9745",
32
- "oaworks.py": "491ae10ae479001fd1218d3d9c623cb39f4e8c222fc3bdf3420b2a8f03328410",
33
- "openalex.py": "434db8364c7f2893161f577a4008974d7ad6d6666b1891dac9fa1e9e05c75618",
34
- "openpolicyfinder.py": "9f55d2e744b6f784422a0731f01c0b727c3cc5b001da009889e8dcf83288f081",
35
- "policy_evaluation.py": "287df1a54c2d4893cd495da6d95ee8049f480bf0ff75879e52f2b0ede7307dab",
36
- "prompts.py": "5b40d1be17b41b8a51231378bf70dee4056d5373c1a739b1a964094f93850359",
37
- "repair_subject_quartiles.py": "91ddce1c0a08fa802923ff1806d5333617f4262eca686ef50ae35b3f35dd5950",
38
- "repositories.py": "fcde7418a711296acfe9c552b284a4c79aa4e741034a6d50854880bfcfae0562",
39
- "requirements-dev.txt": "e5bfd68d8ff1bdd7151344031c2e4cb97b187d099f681f4007b6d96ad7dfbdf8",
40
- "requirements.txt": "179482f7f9e3177d035ef04101719117524476a98271dbafab3e3f18a17ce149",
41
- "researchbee_journals.py": "623272ee184e37bf08b4fddd887f7aabd10b6c6cb1999416ddbcc0f5c72309c6",
42
- "runtime_security.py": "8d3466aba8a11fb729f30bf83cd56596b53e935cb88f144539a20aaf9ac7d762",
43
- "scimago_index.py": "63c76aff39873989cfdc287c1d3e9320b9bf839139e1cd65d0d7d8d4b6c53832",
44
- "scimagojr_2025.csv": "a536c49b0854e96c66350c92b6f50dcfed26845e7c5545e5ebc65c1f96058fe2",
45
- "style.css": "10762d70e0c991a6bb989c56bf6d22bb2e53ba902d1c9bc4ce7011d8d9f98699",
46
- "subject_quartile_repair.json": "13fb100a9aaabacd9182551ccae8b531da8ae878789692402adde245d8300edb",
47
- "tests/a": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
48
- "tests/conftest.py": "d8a6d008056392e4dbcf83d1970c8d077d94cd8363804727a8ca0c5fe4538edf",
49
- "tests/test_frontend_links.cjs": "2b9eaab29a62de749219d1da27d2c6ea68c6774a17a205c837cbc187fc6fe1b9",
50
- "tests/test_frontend_release_a.py": "ae8f029cd7a6e3ebc404c6cffdf35b3a9a3188cb65f93ff5f9c0cbcfb344947a",
51
- "tests/test_journal_policy_guard.py": "27ece48b44981ba75a0335dbcca5ec5a09dfa57f5976b037e2dbd7155ff4990b",
52
- "tests/test_khazna_reload_auth.py": "c9fe67180ca23b5d0adf0d8f91bd21e256d929a993a61f07a02ae7d111091013",
53
- "tests/test_oaworks_tri_state.py": "0bd94f8af28d39c01c42a821ad79604699690d8801ffcc39e5189b6add5afee2",
54
- "tests/test_repository_schema.py": "658880256ca07349737a52c602544a98153f0c995125970d13cc5fb9c9caf64f",
55
- "tests/test_subject_quartiles.py": "c93529b2e2501f2e4f378dbd73d37cabb388040d907566404f19b97980789676",
56
- "tests/test_trusted_links.py": "5d5ffb6547d86e20601374a8c637be162824b6947fdefec114d621921d49bcbc",
57
- "tests/test_trusted_links_api.py": "c11c5e990d83a67627104f6ad41dcc1b3d8380a76354a4806ab9702e6dafc1b3",
58
- "train_asjc.py": "caff809252a7e3234a4c22f113c4df56e2aafbcfa45398fcfbed3061755d1dae",
59
- "trusted_links.py": "dfbc4490dfb4b80e1b1aa0089fcf39c6cf0866b42d5a032367dae255e25da004",
60
- "usage.py": "5cbe805a1732ed5a11d2a420cf085b6baef63f5c5eff20de3126f6a208848df3",
61
- "utils.py": "57e37e6b426159b5233657bfd442671150647c7b5f291a2d1a6ba5938b3a42b7"
62
- }
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ ".dockerignore": "acbfa007f9cc614a581b5517709de7fb6df4a1836bc7c0c7bee7b96d020f3b83",
3
+ ".env.example": "b3ad9769d1306238eec831d7a818a21b15cd7cdb3df8b670d49831fb10beb6b8",
4
+ ".gitattributes": "4d72e9b97f1cb0fd76258dec00ff2a9ac929fafab43ac1e68da5d4ab82149622",
5
+ "DEPLOYMENT-LINKS.md": "f58b06a9ebf2558c3699025038c5671d1eb45c3e3ff989c9300ed586800a9347",
6
+ "Dockerfile": "a5289268fe287dd5677b37ce60017563f7294729bed078c935cfce3fc010d280",
7
+ "README-DEPLOY.md": "74103d9cc21b69354e5c978910edd7e22b24cf21c3ef657f82102178b0cd962c",
8
+ "README.md": "4b8cb6a850fdecf36e0d8efb70ec7fe9c916edbfa82db367d9126c9df51166e4",
9
+ "RELEASE_1.7.0.md": "334d4cb6215ff7ab540c539d3c3f8d2830641ced95c4f9325fdd86c78b7488ce",
10
+ "RELEASE_1.8.0.md": "4d85c1711c33cd314c799d27e59795b62fe9104656d79cd76bba6af538b9ad1e",
11
+ "RELEASE_1.8.1.md": "500ba91d0737c6198a453de2bd539827b019185c34d68d32d325a2dc5e106fa4",
12
+ "RELEASE_1.8.2.md": "b006bed1643a39a8f79930ffb682c37c64ba00a3752b9a056993791153810f88",
13
+ "RELEASE_1.8.3.md": "9d0aace78e561326ab1c74e63dc801029fc2c958b656d82f6325242a2476c491",
14
+ "RELEASE_A_NOTES.md": "2c9d4dd7b8e787ada145b2393578c6b3499370fb4be2a89100bd555ed8b96a42",
15
+ "RELEASE_OPF_WEB_1.5.2.md": "b4cc66479031664e35026fdbb57ec0a83468e1d54100ac022100b2849df67b77",
16
+ "ResearchBee_LibGuides.html": "2ad265c90e98d2d30ded0754d8fefb2f0137b3f1f0b8416aaacfc42718764219",
17
+ "VALIDATION_1.8.2.md": "2c5ae04d43ebe3e3867bd5fbb4bdf303cebf03a7dc85dddc0079844b3ec82f41",
18
+ "admin.py": "7db70eb130f8bc4d9e98f566ce86315fd2dfa4cbe90e3415dba037aa13d28421",
19
+ "app.py": "ded10bb7abc502c1142c6a513ff1f1d4950f70d4e47df5351cbb0172ef849a23",
20
+ "asjc_classifier.py": "c689f9d1fcd3ac15b4a0bd711153acbc9cb8780caed220f0c5d6afcde9af6fa6",
21
+ "asjc_eval.jsonl": "cc762d1d81532574a2270b98758324dc7c251b1d22a58604045ca5fa02a1b684",
22
+ "build_centroids.py": "48b3064a7b261195824fdedf84708ebb6e36954b9160d4b1722235d548e93e42",
23
+ "chat_knowledge.py": "d8b69d510ae847bb46f2c054a1458df7fe4a8db237dfb94ae515cfd4dff414ad",
24
+ "doi_utils.py": "7026a3913998b94aa4822f7cbbe9bb779ce52880f6e5a180959b37a19de9f8f9",
25
+ "harvest_openalex.py": "3c3cedd28c0892a10126249d892cd27275131d5295669d10d201ebae9ea4da3d",
26
+ "journal_discovery.py": "b69d282411eb4fecd3bcdb354cc34ac27f705b5b1d71ae600372d75a4672c12d",
27
+ "journal_links_curated.json": "e3566b3a06430868d71e9287dfd6c6c520a3da027aabea01951d407ee131dc2f",
28
+ "journal_lookup.py": "4d7e1638b48cfd1c908b217c3e4b8bf145932a9f34d3cb762092a99e4a817aa0",
29
+ "journals.db": "6553cefa4515cbeea4631fcd5f59a4b8bdb756fe83701788e8a11fb9298b2404",
30
+ "khazna_loader.py": "3e944cbfa116647ff548015b37e083134da9aa2aafbda0e957be6c31ea7ac9c7",
31
+ "knowledge_base.json": "1838c1d1fd5456c16308ebfaba237b8516aa01df5901a83e7290bb73b55b5bc6",
32
+ "ku_knowledge.py": "b54c5acd3c26783e5b44872897810d04ae0257df367b9fde62e8b6bb6e5f9745",
33
+ "oaworks.py": "491ae10ae479001fd1218d3d9c623cb39f4e8c222fc3bdf3420b2a8f03328410",
34
+ "openalex.py": "1f846a13c46d7dd4ed70494a552a1bfd596cf764d7fb7f8b89ac3389a9cf195c",
35
+ "openpolicyfinder.py": "9f55d2e744b6f784422a0731f01c0b727c3cc5b001da009889e8dcf83288f081",
36
+ "policy_evaluation.py": "287df1a54c2d4893cd495da6d95ee8049f480bf0ff75879e52f2b0ede7307dab",
37
+ "prompts.py": "5b40d1be17b41b8a51231378bf70dee4056d5373c1a739b1a964094f93850359",
38
+ "re3data.py": "fee9fb9a36c77b9ab2da9e63baa54f7f74b88a864ac5d39df0d6804f6904c25e",
39
+ "repair_subject_quartiles.py": "91ddce1c0a08fa802923ff1806d5333617f4262eca686ef50ae35b3f35dd5950",
40
+ "repositories.py": "f0f1673a2b73a8afc9be6b20a96bd3396336513550570d8b5048000ed75fa5a4",
41
+ "requirements-dev.txt": "e5bfd68d8ff1bdd7151344031c2e4cb97b187d099f681f4007b6d96ad7dfbdf8",
42
+ "requirements.txt": "179482f7f9e3177d035ef04101719117524476a98271dbafab3e3f18a17ce149",
43
+ "researchbee_journals.py": "623272ee184e37bf08b4fddd887f7aabd10b6c6cb1999416ddbcc0f5c72309c6",
44
+ "runtime_security.py": "8d3466aba8a11fb729f30bf83cd56596b53e935cb88f144539a20aaf9ac7d762",
45
+ "scimago_index.py": "8ec98c45527c7af397b9c3d0d1f4a23319e2bebbcb0c2642727559b9c3909a80",
46
+ "scimagojr_2025.csv": "a536c49b0854e96c66350c92b6f50dcfed26845e7c5545e5ebc65c1f96058fe2",
47
+ "style.css": "10762d70e0c991a6bb989c56bf6d22bb2e53ba902d1c9bc4ce7011d8d9f98699",
48
+ "subject_quartile_repair.json": "13fb100a9aaabacd9182551ccae8b531da8ae878789692402adde245d8300edb",
49
+ "tests/a": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
50
+ "tests/conftest.py": "d8a6d008056392e4dbcf83d1970c8d077d94cd8363804727a8ca0c5fe4538edf",
51
+ "tests/fixtures/re3data_r3d100010134.xml": "7d6a2c0c6a655529dec1d69af12ec949369268d75ddaa7723e3ca5449d304683",
52
+ "tests/fixtures/re3data_r3d100012345.xml": "6ad59819be352abc72b34127aa59813ee128196651783d051224f6e7355331cd",
53
+ "tests/fixtures/re3data_r3d100012346.xml": "ef4d110920251213e1da2fa4de7ac34b047164e59923390e9df3a6f075b3f2c8",
54
+ "tests/fixtures/re3data_r3d100012347.xml": "7aea33c7ad7c38d7c6288a913237eaed235d40c0162af7bb5b7cd2f4a6cd3775",
55
+ "tests/fixtures/re3data_r3d100012348.xml": "2220f3b9ee3665707c161cec6b0d07019541a902a6f028d8aeb1ff6ae14de458",
56
+ "tests/fixtures/re3data_search_materials.xml": "9cd339477b3184a48027316baa576fcb367353356311c2c3e8bd3420f7673a83",
57
+ "tests/test_frontend_links.cjs": "33bf2edca82a17ef98df86221beb1ca0fddfaf52289dfc9a7cc161e657c7e551",
58
+ "tests/test_frontend_re3data.cjs": "1f934c661982b6e00a0676ac2e5b36b4aef302d5b0a4d41858ea8a24a68f0e41",
59
+ "tests/test_frontend_release_a.py": "ae8f029cd7a6e3ebc404c6cffdf35b3a9a3188cb65f93ff5f9c0cbcfb344947a",
60
+ "tests/test_journal_policy_guard.py": "27ece48b44981ba75a0335dbcca5ec5a09dfa57f5976b037e2dbd7155ff4990b",
61
+ "tests/test_khazna_reload_auth.py": "c9fe67180ca23b5d0adf0d8f91bd21e256d929a993a61f07a02ae7d111091013",
62
+ "tests/test_oaworks_tri_state.py": "0bd94f8af28d39c01c42a821ad79604699690d8801ffcc39e5189b6add5afee2",
63
+ "tests/test_re3data.py": "62388760aed721d86172bc617cc88ab3e2cb20eb3f2974aff9c2f3b5ff5f76a2",
64
+ "tests/test_repository_schema.py": "658880256ca07349737a52c602544a98153f0c995125970d13cc5fb9c9caf64f",
65
+ "tests/test_subject_nearest.py": "d9d4e9f07ae5399201fb376885171378d5f47842be4b41072c476e86ce80be06",
66
+ "tests/test_subject_quartiles.py": "c93529b2e2501f2e4f378dbd73d37cabb388040d907566404f19b97980789676",
67
+ "tests/test_trusted_links.py": "05e2e6a614ab61bbf44bb9b937ef00b831ec4f5e7c1dd2b2557c536e56feb8ba",
68
+ "tests/test_trusted_links_api.py": "b3d5d1f765fc451f13e514559a1ee53dbd1b7c51ad6a3736a47c75b878d619e0",
69
+ "train_asjc.py": "caff809252a7e3234a4c22f113c4df56e2aafbcfa45398fcfbed3061755d1dae",
70
+ "trusted_links.py": "8cbc9e55a9682af3991ea42cd040df89bdce02347919114bdb7236e97ad97c00",
71
+ "usage.py": "5cbe805a1732ed5a11d2a420cf085b6baef63f5c5eff20de3126f6a208848df3",
72
+ "utils.py": "57e37e6b426159b5233657bfd442671150647c7b5f291a2d1a6ba5938b3a42b7"
73
+ }
app.py CHANGED
The diff for this file is too large to render. See raw diff
 
journal_links_curated.json CHANGED
@@ -1 +1 @@
1
- {}
 
1
+ {}
openalex.py CHANGED
@@ -1,307 +1,307 @@
1
- """
2
- OpenAlex enrichment — journal metadata + peer percentile calculation.
3
- Optional server-side API key raises the available request budget.
4
- The obsolete sample-derived percentile is deliberately not reported.
5
-
6
- Changes from v1.0:
7
- - Shared module-level httpx.AsyncClient (init_client / close_client for lifespan)
8
- - compute_percentile accepts an optional concept_cache dict to deduplicate
9
- identical concept peer-set fetches across journals in one request
10
- - _norm_issn removed — uses utils.norm_issn
11
- """
12
- import httpx
13
- import asyncio
14
- import re
15
- import os
16
- import time
17
- import logging
18
- from collections import OrderedDict
19
- from datetime import datetime, timezone
20
- from typing import Optional
21
- from utils import norm_issn
22
-
23
- UA = "ResearchBee/1.8.2 (mailto:library@ku.ac.ae)"
24
- logger = logging.getLogger(__name__)
25
- _last_success = None
26
- _last_error = None
27
- _source_cache = OrderedDict()
28
- BASE = "https://api.openalex.org"
29
-
30
- _http_client: Optional[httpx.AsyncClient] = None
31
-
32
-
33
- async def _record_response(response) -> None:
34
- global _last_success, _last_error
35
- now = datetime.now(timezone.utc).isoformat(timespec="seconds")
36
- if response.is_success:
37
- _last_success = now
38
- _last_error = None
39
- else:
40
- _last_error = {"status": response.status_code, "at": now}
41
-
42
-
43
- def status() -> dict:
44
- return {"api_key_configured": bool((os.getenv("OPENALEX_API_KEY") or "").strip()),
45
- "last_success": _last_success, "last_error": _last_error,
46
- "availability": "not_checked" if _last_success is None and _last_error is None else ("degraded" if _last_error else "available")}
47
-
48
-
49
- async def _authorize_request(request) -> None:
50
- """Enforce the provider boundary before attaching its credential."""
51
- url = request.url
52
- if url.scheme != "https" or url.host != "api.openalex.org" or url.port not in (None, 443):
53
- request.headers.pop("Authorization", None)
54
- raise ValueError("OpenAlex client refuses non-provider origins")
55
- key = (os.getenv("OPENALEX_API_KEY") or "").strip()
56
- if key:
57
- request.headers["Authorization"] = "Bearer " + key
58
- else:
59
- request.headers.pop("Authorization", None)
60
-
61
-
62
- def _record_failure(exc: Exception) -> None:
63
- global _last_error
64
- _last_error = {"type": type(exc).__name__,
65
- "at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
66
- status_code = getattr(exc, "status_code", None)
67
- if status_code is not None:
68
- _last_error["status"] = status_code
69
-
70
-
71
- class OpenAlexUnavailable(RuntimeError):
72
- def __init__(self, status_code=None):
73
- self.status_code = status_code
74
- super().__init__("OpenAlex is temporarily unavailable")
75
-
76
-
77
- def init_client() -> None:
78
- global _http_client
79
- _http_client = httpx.AsyncClient(
80
- timeout=httpx.Timeout(10.0, connect=5.0),
81
- headers={"User-Agent": UA}, follow_redirects=False,
82
- event_hooks={"request": [_authorize_request], "response": [_record_response]},
83
- )
84
-
85
-
86
- async def close_client() -> None:
87
- global _http_client
88
- if _http_client and not _http_client.is_closed:
89
- await _http_client.aclose()
90
- _http_client = None
91
-
92
-
93
- def _get_client() -> httpx.AsyncClient:
94
- if _http_client is None or _http_client.is_closed:
95
- init_client()
96
- return _http_client
97
-
98
-
99
- async def enrich_journal(name: str, issn: Optional[str]) -> Optional[dict]:
100
- cache_key = (norm_issn(issn), name.strip().casefold())
101
- cached = _source_cache.get(cache_key)
102
- if cached and time.monotonic() - cached[0] < 3600:
103
- _source_cache.move_to_end(cache_key)
104
- return dict(cached[1])
105
- client = _get_client()
106
- try:
107
- if issn:
108
- clean = norm_issn(issn) or issn.strip()
109
- r = await client.get(f"{BASE}/sources/issn:{clean}")
110
- else:
111
- r = await client.get(
112
- f"{BASE}/sources", params={"search": name, "per_page": 1}
113
- )
114
- if not r.is_success:
115
- return None
116
- d = r.json()
117
- s = d if issn else (d.get("results") or [None])[0]
118
- if not s:
119
- return None
120
- # An ISSN request must be confirmed by the response, not merely the URL.
121
- source_issns = {norm_issn(v) for v in [s.get("issn_l"), *(s.get("issn") or [])] if v}
122
- if issn and norm_issn(issn) not in source_issns:
123
- return None
124
- if not issn:
125
- from researchbee_journals import JournalIndex
126
- if JournalIndex.normalise_identity_title(name) != JournalIndex.normalise_identity_title(s.get("display_name", "")):
127
- return None
128
- result = {
129
- "source_updated_at": s.get("updated_date"),
130
- "retrieved_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
131
- "openalex_id": s.get("id"),
132
- "display_name": s.get("display_name"),
133
- "issn_l": s.get("issn_l"),
134
- "issns": s.get("issn", []),
135
- "host_organization": s.get("host_organization_name"),
136
- "is_oa": s.get("is_oa"),
137
- "is_in_doaj": s.get("is_in_doaj"),
138
- "works_count": s.get("works_count"),
139
- "cited_by_count": s.get("cited_by_count"),
140
- "h_index": (s.get("summary_stats") or {}).get("h_index"),
141
- "two_yr_mean_citedness": (s.get("summary_stats") or {}).get("2yr_mean_citedness"),
142
- "homepage_url": s.get("homepage_url"),
143
- "top_concept": ((s.get("x_concepts") or [{}])[0]).get("display_name"),
144
- "top_concept_id": ((s.get("x_concepts") or [{}])[0]).get("id"),
145
- }
146
- _source_cache[cache_key] = (time.monotonic(), result)
147
- while len(_source_cache) > 512:
148
- _source_cache.popitem(last=False)
149
- return dict(result)
150
- except Exception as e:
151
- _record_failure(e)
152
- logger.warning("OpenAlex source lookup failed (%s)", type(e).__name__)
153
- return None
154
-
155
-
156
- async def compute_percentile(openalex: dict, concept_cache: Optional[dict] = None) -> Optional[dict]:
157
- """A truncated high-citation peer sample cannot establish a field quartile."""
158
- return None
159
-
160
-
161
- async def enrich_all(journals: list) -> list:
162
- """Enrich a list of journal dicts in parallel, deduplicating concept peer-sets."""
163
- concept_cache: dict = {}
164
-
165
- async def _enrich_one(j: dict) -> dict:
166
- oa = await enrich_journal(j.get("name", ""), j.get("issn"))
167
- pct = await compute_percentile(oa, concept_cache) if oa else None
168
- j["openalex"] = oa
169
- j["oa_percentile"] = pct
170
- if not j.get("issn") and oa:
171
- j["issn"] = oa.get("issn_l") or (oa.get("issns") or [None])[0]
172
- return j
173
-
174
- return list(await asyncio.gather(*[_enrich_one(j) for j in journals]))
175
-
176
-
177
- _EVIDENCE_STOP = {
178
- "this", "that", "with", "from", "into", "using", "used", "study",
179
- "research", "paper", "article", "results", "method", "methods", "based",
180
- "analysis", "approach", "between", "among", "their", "these", "those",
181
- }
182
-
183
-
184
- def _evidence_terms(text: str) -> set[str]:
185
- words = re.findall(r"[A-Za-z][A-Za-z\-]{3,}", (text or "").lower())
186
- return {w.strip("-") for w in words if w not in _EVIDENCE_STOP}
187
-
188
-
189
- def _title_overlap(query: str, titles: list[str]) -> int:
190
- q = _evidence_terms(query)
191
- if not q or not titles:
192
- return 0
193
- scores = []
194
- for title in titles:
195
- t = _evidence_terms(title)
196
- if not t:
197
- continue
198
- overlap = len(q & t)
199
- # Dice coefficient, converted to 0..100. OpenAlex already orders the
200
- # search by relevance; this score is only a transparent local signal.
201
- scores.append((2 * overlap / max(len(q) + len(t), 1)) * 100)
202
- return round(max(scores, default=0.0))
203
-
204
-
205
- async def similar_works_for_journal(
206
- name: str,
207
- issn: str = "",
208
- query: str = "",
209
- *,
210
- per_page: int = 3,
211
- ) -> dict:
212
- """Return representative recent works as evidence for journal fit.
213
-
214
- Failure is explicitly labelled unavailable, separately from no matches. Journal
215
- identity and ranking still come from the local verified database, so an
216
- OpenAlex outage never removes or invents a recommendation.
217
- """
218
- source = await enrich_journal(name, issn or None)
219
- if not source or not source.get("openalex_id"):
220
- return {"source": source, "works": [], "similarity_score": 0, "status": "unavailable"}
221
-
222
- source_id = str(source["openalex_id"]).rsplit("/", 1)[-1]
223
- if not re.fullmatch(r"S\d+", source_id):
224
- return {"source": source, "works": [], "similarity_score": 0, "status": "unavailable"}
225
-
226
- current_year = datetime.now(timezone.utc).year
227
- query_text = " ".join((query or "").split())[:500]
228
- client = _get_client()
229
-
230
- async def _fetch(use_search: bool) -> list[dict]:
231
- params = {
232
- "filter": (
233
- f"primary_location.source.id:{source_id},is_retracted:false,"
234
- f"from_publication_date:{current_year - 5}-01-01"
235
- ),
236
- "per_page": min(max(per_page, 1), 5),
237
- "select": "id,title,publication_year,doi,cited_by_count,authorships",
238
- }
239
- if use_search and query_text:
240
- params["search"] = query_text
241
- params["sort"] = "relevance_score:desc"
242
- else:
243
- params["sort"] = "cited_by_count:desc"
244
- response = await client.get(f"{BASE}/works", params=params, timeout=6.0)
245
- if not response.is_success:
246
- raise OpenAlexUnavailable(response.status_code)
247
- works = []
248
- for work in response.json().get("results", []):
249
- title = " ".join(str(work.get("title") or "").split())
250
- if not title:
251
- continue
252
- authors = [
253
- (a.get("author") or {}).get("display_name", "")
254
- for a in (work.get("authorships") or [])[:3]
255
- ]
256
- works.append({
257
- "title": title,
258
- "year": work.get("publication_year"),
259
- "doi": work.get("doi", ""),
260
- "cited_by": work.get("cited_by_count", 0),
261
- "authors": [a for a in authors if a],
262
- "openalex_url": work.get("id", ""),
263
- })
264
- return works
265
-
266
- try:
267
- works = await _fetch(bool(query_text))
268
- if not works:
269
- works = await _fetch(False)
270
- score = _title_overlap(query_text, [w["title"] for w in works])
271
- return {
272
- "source": source,
273
- "works": works,
274
- "similarity_score": score,
275
- "status": "available" if works else "no_recent_matches",
276
- }
277
- except Exception as exc:
278
- _record_failure(exc)
279
- logger.warning("OpenAlex evidence unavailable (%s)", type(exc).__name__)
280
- return {"source": source, "works": [], "similarity_score": 0, "status": "unavailable"}
281
-
282
-
283
- async def evidence_for_journals(
284
- journals: list[dict],
285
- query: str,
286
- *,
287
- limit: int = 12,
288
- per_page: int = 3,
289
- concurrency: int = 5,
290
- ) -> dict[str, dict]:
291
- """Fetch representative works for the strongest verified candidates."""
292
- semaphore = asyncio.Semaphore(max(1, concurrency))
293
-
294
- async def _one(journal: dict) -> tuple[str, dict]:
295
- key = str(journal.get("id") or journal.get("scopus_id") or journal.get("issn") or journal.get("name"))
296
- async with semaphore:
297
- evidence = await similar_works_for_journal(
298
- journal.get("name", ""),
299
- journal.get("issn", ""),
300
- query,
301
- per_page=per_page,
302
- )
303
- return key, evidence
304
-
305
- selected = journals[: max(0, limit)]
306
- pairs = await asyncio.gather(*[_one(j) for j in selected]) if selected else []
307
- return {key: evidence for key, evidence in pairs}
 
1
+ """
2
+ OpenAlex enrichment — journal metadata + peer percentile calculation.
3
+ Optional server-side API key raises the available request budget.
4
+ The obsolete sample-derived percentile is deliberately not reported.
5
+
6
+ Changes from v1.0:
7
+ - Shared module-level httpx.AsyncClient (init_client / close_client for lifespan)
8
+ - compute_percentile accepts an optional concept_cache dict to deduplicate
9
+ identical concept peer-set fetches across journals in one request
10
+ - _norm_issn removed — uses utils.norm_issn
11
+ """
12
+ import httpx
13
+ import asyncio
14
+ import re
15
+ import os
16
+ import time
17
+ import logging
18
+ from collections import OrderedDict
19
+ from datetime import datetime, timezone
20
+ from typing import Optional
21
+ from utils import norm_issn
22
+
23
+ UA = "ResearchBee/1.8.2 (mailto:library@ku.ac.ae)"
24
+ logger = logging.getLogger(__name__)
25
+ _last_success = None
26
+ _last_error = None
27
+ _source_cache = OrderedDict()
28
+ BASE = "https://api.openalex.org"
29
+
30
+ _http_client: Optional[httpx.AsyncClient] = None
31
+
32
+
33
+ async def _record_response(response) -> None:
34
+ global _last_success, _last_error
35
+ now = datetime.now(timezone.utc).isoformat(timespec="seconds")
36
+ if response.is_success:
37
+ _last_success = now
38
+ _last_error = None
39
+ else:
40
+ _last_error = {"status": response.status_code, "at": now}
41
+
42
+
43
+ def status() -> dict:
44
+ return {"api_key_configured": bool((os.getenv("OPENALEX_API_KEY") or "").strip()),
45
+ "last_success": _last_success, "last_error": _last_error,
46
+ "availability": "not_checked" if _last_success is None and _last_error is None else ("degraded" if _last_error else "available")}
47
+
48
+
49
+ async def _authorize_request(request) -> None:
50
+ """Enforce the provider boundary before attaching its credential."""
51
+ url = request.url
52
+ if url.scheme != "https" or url.host != "api.openalex.org" or url.port not in (None, 443):
53
+ request.headers.pop("Authorization", None)
54
+ raise ValueError("OpenAlex client refuses non-provider origins")
55
+ key = (os.getenv("OPENALEX_API_KEY") or "").strip()
56
+ if key:
57
+ request.headers["Authorization"] = "Bearer " + key
58
+ else:
59
+ request.headers.pop("Authorization", None)
60
+
61
+
62
+ def _record_failure(exc: Exception) -> None:
63
+ global _last_error
64
+ _last_error = {"type": type(exc).__name__,
65
+ "at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
66
+ status_code = getattr(exc, "status_code", None)
67
+ if status_code is not None:
68
+ _last_error["status"] = status_code
69
+
70
+
71
+ class OpenAlexUnavailable(RuntimeError):
72
+ def __init__(self, status_code=None):
73
+ self.status_code = status_code
74
+ super().__init__("OpenAlex is temporarily unavailable")
75
+
76
+
77
+ def init_client() -> None:
78
+ global _http_client
79
+ _http_client = httpx.AsyncClient(
80
+ timeout=httpx.Timeout(10.0, connect=5.0),
81
+ headers={"User-Agent": UA}, follow_redirects=False,
82
+ event_hooks={"request": [_authorize_request], "response": [_record_response]},
83
+ )
84
+
85
+
86
+ async def close_client() -> None:
87
+ global _http_client
88
+ if _http_client and not _http_client.is_closed:
89
+ await _http_client.aclose()
90
+ _http_client = None
91
+
92
+
93
+ def _get_client() -> httpx.AsyncClient:
94
+ if _http_client is None or _http_client.is_closed:
95
+ init_client()
96
+ return _http_client
97
+
98
+
99
+ async def enrich_journal(name: str, issn: Optional[str]) -> Optional[dict]:
100
+ cache_key = (norm_issn(issn), name.strip().casefold())
101
+ cached = _source_cache.get(cache_key)
102
+ if cached and time.monotonic() - cached[0] < 3600:
103
+ _source_cache.move_to_end(cache_key)
104
+ return dict(cached[1])
105
+ client = _get_client()
106
+ try:
107
+ if issn:
108
+ clean = norm_issn(issn) or issn.strip()
109
+ r = await client.get(f"{BASE}/sources/issn:{clean}")
110
+ else:
111
+ r = await client.get(
112
+ f"{BASE}/sources", params={"search": name, "per_page": 1}
113
+ )
114
+ if not r.is_success:
115
+ return None
116
+ d = r.json()
117
+ s = d if issn else (d.get("results") or [None])[0]
118
+ if not s:
119
+ return None
120
+ # An ISSN request must be confirmed by the response, not merely the URL.
121
+ source_issns = {norm_issn(v) for v in [s.get("issn_l"), *(s.get("issn") or [])] if v}
122
+ if issn and norm_issn(issn) not in source_issns:
123
+ return None
124
+ if not issn:
125
+ from researchbee_journals import JournalIndex
126
+ if JournalIndex.normalise_identity_title(name) != JournalIndex.normalise_identity_title(s.get("display_name", "")):
127
+ return None
128
+ result = {
129
+ "source_updated_at": s.get("updated_date"),
130
+ "retrieved_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
131
+ "openalex_id": s.get("id"),
132
+ "display_name": s.get("display_name"),
133
+ "issn_l": s.get("issn_l"),
134
+ "issns": s.get("issn", []),
135
+ "host_organization": s.get("host_organization_name"),
136
+ "is_oa": s.get("is_oa"),
137
+ "is_in_doaj": s.get("is_in_doaj"),
138
+ "works_count": s.get("works_count"),
139
+ "cited_by_count": s.get("cited_by_count"),
140
+ "h_index": (s.get("summary_stats") or {}).get("h_index"),
141
+ "two_yr_mean_citedness": (s.get("summary_stats") or {}).get("2yr_mean_citedness"),
142
+ "homepage_url": s.get("homepage_url"),
143
+ "top_concept": ((s.get("x_concepts") or [{}])[0]).get("display_name"),
144
+ "top_concept_id": ((s.get("x_concepts") or [{}])[0]).get("id"),
145
+ }
146
+ _source_cache[cache_key] = (time.monotonic(), result)
147
+ while len(_source_cache) > 512:
148
+ _source_cache.popitem(last=False)
149
+ return dict(result)
150
+ except Exception as e:
151
+ _record_failure(e)
152
+ logger.warning("OpenAlex source lookup failed (%s)", type(e).__name__)
153
+ return None
154
+
155
+
156
+ async def compute_percentile(openalex: dict, concept_cache: Optional[dict] = None) -> Optional[dict]:
157
+ """A truncated high-citation peer sample cannot establish a field quartile."""
158
+ return None
159
+
160
+
161
+ async def enrich_all(journals: list) -> list:
162
+ """Enrich a list of journal dicts in parallel, deduplicating concept peer-sets."""
163
+ concept_cache: dict = {}
164
+
165
+ async def _enrich_one(j: dict) -> dict:
166
+ oa = await enrich_journal(j.get("name", ""), j.get("issn"))
167
+ pct = await compute_percentile(oa, concept_cache) if oa else None
168
+ j["openalex"] = oa
169
+ j["oa_percentile"] = pct
170
+ if not j.get("issn") and oa:
171
+ j["issn"] = oa.get("issn_l") or (oa.get("issns") or [None])[0]
172
+ return j
173
+
174
+ return list(await asyncio.gather(*[_enrich_one(j) for j in journals]))
175
+
176
+
177
+ _EVIDENCE_STOP = {
178
+ "this", "that", "with", "from", "into", "using", "used", "study",
179
+ "research", "paper", "article", "results", "method", "methods", "based",
180
+ "analysis", "approach", "between", "among", "their", "these", "those",
181
+ }
182
+
183
+
184
+ def _evidence_terms(text: str) -> set[str]:
185
+ words = re.findall(r"[A-Za-z][A-Za-z\-]{3,}", (text or "").lower())
186
+ return {w.strip("-") for w in words if w not in _EVIDENCE_STOP}
187
+
188
+
189
+ def _title_overlap(query: str, titles: list[str]) -> int:
190
+ q = _evidence_terms(query)
191
+ if not q or not titles:
192
+ return 0
193
+ scores = []
194
+ for title in titles:
195
+ t = _evidence_terms(title)
196
+ if not t:
197
+ continue
198
+ overlap = len(q & t)
199
+ # Dice coefficient, converted to 0..100. OpenAlex already orders the
200
+ # search by relevance; this score is only a transparent local signal.
201
+ scores.append((2 * overlap / max(len(q) + len(t), 1)) * 100)
202
+ return round(max(scores, default=0.0))
203
+
204
+
205
+ async def similar_works_for_journal(
206
+ name: str,
207
+ issn: str = "",
208
+ query: str = "",
209
+ *,
210
+ per_page: int = 3,
211
+ ) -> dict:
212
+ """Return representative recent works as evidence for journal fit.
213
+
214
+ Failure is explicitly labelled unavailable, separately from no matches. Journal
215
+ identity and ranking still come from the local verified database, so an
216
+ OpenAlex outage never removes or invents a recommendation.
217
+ """
218
+ source = await enrich_journal(name, issn or None)
219
+ if not source or not source.get("openalex_id"):
220
+ return {"source": source, "works": [], "similarity_score": 0, "status": "unavailable"}
221
+
222
+ source_id = str(source["openalex_id"]).rsplit("/", 1)[-1]
223
+ if not re.fullmatch(r"S\d+", source_id):
224
+ return {"source": source, "works": [], "similarity_score": 0, "status": "unavailable"}
225
+
226
+ current_year = datetime.now(timezone.utc).year
227
+ query_text = " ".join((query or "").split())[:500]
228
+ client = _get_client()
229
+
230
+ async def _fetch(use_search: bool) -> list[dict]:
231
+ params = {
232
+ "filter": (
233
+ f"primary_location.source.id:{source_id},is_retracted:false,"
234
+ f"from_publication_date:{current_year - 5}-01-01"
235
+ ),
236
+ "per_page": min(max(per_page, 1), 5),
237
+ "select": "id,title,publication_year,doi,cited_by_count,authorships",
238
+ }
239
+ if use_search and query_text:
240
+ params["search"] = query_text
241
+ params["sort"] = "relevance_score:desc"
242
+ else:
243
+ params["sort"] = "cited_by_count:desc"
244
+ response = await client.get(f"{BASE}/works", params=params, timeout=6.0)
245
+ if not response.is_success:
246
+ raise OpenAlexUnavailable(response.status_code)
247
+ works = []
248
+ for work in response.json().get("results", []):
249
+ title = " ".join(str(work.get("title") or "").split())
250
+ if not title:
251
+ continue
252
+ authors = [
253
+ (a.get("author") or {}).get("display_name", "")
254
+ for a in (work.get("authorships") or [])[:3]
255
+ ]
256
+ works.append({
257
+ "title": title,
258
+ "year": work.get("publication_year"),
259
+ "doi": work.get("doi", ""),
260
+ "cited_by": work.get("cited_by_count", 0),
261
+ "authors": [a for a in authors if a],
262
+ "openalex_url": work.get("id", ""),
263
+ })
264
+ return works
265
+
266
+ try:
267
+ works = await _fetch(bool(query_text))
268
+ if not works:
269
+ works = await _fetch(False)
270
+ score = _title_overlap(query_text, [w["title"] for w in works])
271
+ return {
272
+ "source": source,
273
+ "works": works,
274
+ "similarity_score": score,
275
+ "status": "available" if works else "no_recent_matches",
276
+ }
277
+ except Exception as exc:
278
+ _record_failure(exc)
279
+ logger.warning("OpenAlex evidence unavailable (%s)", type(exc).__name__)
280
+ return {"source": source, "works": [], "similarity_score": 0, "status": "unavailable"}
281
+
282
+
283
+ async def evidence_for_journals(
284
+ journals: list[dict],
285
+ query: str,
286
+ *,
287
+ limit: int = 12,
288
+ per_page: int = 3,
289
+ concurrency: int = 5,
290
+ ) -> dict[str, dict]:
291
+ """Fetch representative works for the strongest verified candidates."""
292
+ semaphore = asyncio.Semaphore(max(1, concurrency))
293
+
294
+ async def _one(journal: dict) -> tuple[str, dict]:
295
+ key = str(journal.get("id") or journal.get("scopus_id") or journal.get("issn") or journal.get("name"))
296
+ async with semaphore:
297
+ evidence = await similar_works_for_journal(
298
+ journal.get("name", ""),
299
+ journal.get("issn", ""),
300
+ query,
301
+ per_page=per_page,
302
+ )
303
+ return key, evidence
304
+
305
+ selected = journals[: max(0, limit)]
306
+ pairs = await asyncio.gather(*[_one(j) for j in selected]) if selected else []
307
+ return {key: evidence for key, evidence in pairs}
re3data.py ADDED
@@ -0,0 +1,589 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ re3data.py — supplementary repository discovery from the re3data.org registry.
3
+
4
+ WHY THIS EXISTS
5
+ ---------------
6
+ The curated KU Library registry (repositories.py) stays the primary source:
7
+ the model selects from it by id and every displayed fact comes from a
8
+ reviewed record. That registry is intentionally small, so a dataset in a
9
+ narrower field may only receive generalist suggestions.
10
+
11
+ This module adds a clearly separated "more options" list from re3data.org,
12
+ the Registry of Research Data Repositories (DataCite). It never changes the
13
+ curated recommendations, the deposit strategy, or Khazna's position.
14
+
15
+ EVIDENCE RULES
16
+ --------------
17
+ * Facts shown for these entries come only from the re3data record, labelled
18
+ as such, with the record's own last-update date. They are not reviewed by
19
+ KU Library and are never merged into curated entries.
20
+ * Repositories already in the curated registry, repositories with an end
21
+ date (closed), repositories whose data upload is closed, and purely
22
+ institutional repositories (usually restricted to their own staff) are
23
+ excluded.
24
+ * Only the researcher-entered *discipline* is sent to re3data, never the
25
+ dataset title, description or notes, which may be unpublished.
26
+
27
+ FAILURE MODEL
28
+ -------------
29
+ Every network, parsing or validation failure returns an "unavailable"
30
+ status and an empty list. Nothing here may raise into the route.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import logging
36
+ import os
37
+ import re
38
+ import time
39
+ from collections import OrderedDict
40
+ from datetime import datetime, timezone
41
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
42
+ from urllib.parse import urlparse
43
+ import xml.etree.ElementTree as ET
44
+
45
+ import httpx
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ API_HOST = "www.re3data.org"
50
+ SEARCH_URL = f"https://{API_HOST}/api/beta/repositories"
51
+ DETAIL_URL = f"https://{API_HOST}/api/v1/repository/{{rid}}"
52
+ RECORD_URL = f"https://{API_HOST}/repository/{{rid}}"
53
+ UA = "ResearchBee/1.8.3 (mailto:library@ku.ac.ae)"
54
+
55
+ ID_RE = re.compile(r"^r3d\d{9}$")
56
+ MAX_RESPONSE_BYTES = 2_000_000
57
+ MAX_DETAIL_FETCHES = 20
58
+ DETAIL_CONCURRENCY = 6
59
+ SEARCH_TTL = 24 * 3600
60
+ FAILURE_TTL = 10 * 60
61
+ DETAIL_TTL = 7 * 24 * 3600
62
+ CACHE_MAX = 256
63
+ DETAIL_CACHE_MAX = 1024
64
+
65
+ _STOPWORDS = {
66
+ "and", "of", "the", "in", "for", "or", "a", "an", "to", "with", "on", "data",
67
+ "dataset", "datasets", "research", "science", "sciences", "studies", "study",
68
+ "general", "applied", "other", "misc", "miscellaneous",
69
+ }
70
+
71
+ _http_client: Optional[httpx.AsyncClient] = None
72
+ _search_cache: "OrderedDict[str, Tuple[float, Any]]" = OrderedDict()
73
+ _detail_cache: "OrderedDict[str, Tuple[float, Optional[dict]]]" = OrderedDict()
74
+ _inflight: Dict[str, asyncio.Future] = {}
75
+ _last_success: Optional[str] = None
76
+ _last_error: Optional[dict] = None
77
+
78
+
79
+ def enabled() -> bool:
80
+ return (os.getenv("RE3DATA_ENABLED", "true").strip().lower() not in {"0", "false", "no", "off"})
81
+
82
+
83
+ def _now_iso() -> str:
84
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
85
+
86
+
87
+ def status() -> dict:
88
+ return {
89
+ "enabled": enabled(),
90
+ "last_success": _last_success,
91
+ "last_error": _last_error,
92
+ "availability": (
93
+ "disabled" if not enabled()
94
+ else "not_checked" if _last_success is None and _last_error is None
95
+ else "degraded" if _last_error else "available"
96
+ ),
97
+ }
98
+
99
+
100
+ # ── HTTP client lifecycle ─────────────────────────────────────────────────
101
+
102
+ async def _enforce_origin(request: httpx.Request) -> None:
103
+ url = request.url
104
+ if url.scheme != "https" or url.host != API_HOST or url.port not in (None, 443):
105
+ raise ValueError("re3data client refuses non-provider origins")
106
+
107
+
108
+ def init_client() -> None:
109
+ global _http_client
110
+ if _http_client is None:
111
+ _http_client = httpx.AsyncClient(
112
+ timeout=httpx.Timeout(6.0, connect=4.0),
113
+ headers={"User-Agent": UA, "Accept": "application/xml, text/xml"},
114
+ follow_redirects=False,
115
+ event_hooks={"request": [_enforce_origin]},
116
+ )
117
+
118
+
119
+ async def close_client() -> None:
120
+ global _http_client
121
+ if _http_client is not None:
122
+ await _http_client.aclose()
123
+ _http_client = None
124
+
125
+
126
+ def _client() -> httpx.AsyncClient:
127
+ if _http_client is None:
128
+ init_client()
129
+ return _http_client # type: ignore[return-value]
130
+
131
+
132
+ # ── Bounded caches ────────────────────────────────────────────────────────
133
+
134
+ def _cache_get(cache: OrderedDict, key: str):
135
+ item = cache.get(key)
136
+ if not item:
137
+ return None
138
+ expires, value = item
139
+ if expires < time.monotonic():
140
+ cache.pop(key, None)
141
+ return None
142
+ cache.move_to_end(key)
143
+ return item
144
+
145
+
146
+ def _cache_put(cache: OrderedDict, key: str, value: Any, ttl: float, limit: int) -> None:
147
+ cache[key] = (time.monotonic() + ttl, value)
148
+ cache.move_to_end(key)
149
+ while len(cache) > limit:
150
+ cache.popitem(last=False)
151
+
152
+
153
+ def clear_caches() -> None:
154
+ _search_cache.clear()
155
+ _detail_cache.clear()
156
+ _inflight.clear()
157
+
158
+
159
+ # ── XML helpers (namespace-agnostic; the schema namespace has changed) ────
160
+
161
+ def _local(tag: Any) -> str:
162
+ return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""
163
+
164
+
165
+ def _safe_parse(content: bytes) -> Optional[ET.Element]:
166
+ if not content or len(content) > MAX_RESPONSE_BYTES:
167
+ return None
168
+ head = content[:4096].upper()
169
+ # re3data never needs a DTD; refusing one blocks entity-expansion tricks.
170
+ if b"<!DOCTYPE" in head or b"<!ENTITY" in content.upper():
171
+ return None
172
+ try:
173
+ return ET.fromstring(content)
174
+ except ET.ParseError:
175
+ return None
176
+
177
+
178
+ def _children(node: ET.Element, name: str) -> List[ET.Element]:
179
+ return [child for child in list(node) if _local(child.tag) == name]
180
+
181
+
182
+ def _text(node: Optional[ET.Element]) -> str:
183
+ if node is None:
184
+ return ""
185
+ return " ".join("".join(node.itertext()).split())
186
+
187
+
188
+ def _first_text(node: ET.Element, name: str) -> str:
189
+ for child in _children(node, name):
190
+ value = _text(child)
191
+ if value:
192
+ return value
193
+ return ""
194
+
195
+
196
+ def _all_text(node: ET.Element, name: str, limit: int = 20, width: int = 200) -> List[str]:
197
+ out: List[str] = []
198
+ for child in node.iter():
199
+ if _local(child.tag) == name:
200
+ value = _text(child)[:width]
201
+ if value and value not in out:
202
+ out.append(value)
203
+ if len(out) >= limit:
204
+ break
205
+ return out
206
+
207
+
208
+ def _https_url(value: str) -> str:
209
+ value = (value or "").strip()
210
+ try:
211
+ parsed = urlparse(value)
212
+ except ValueError:
213
+ return ""
214
+ if parsed.scheme != "https" or not parsed.hostname or "." not in parsed.hostname:
215
+ return ""
216
+ if parsed.username or parsed.password or (parsed.port not in (None, 443)):
217
+ return ""
218
+ return value[:500]
219
+
220
+
221
+ def _host(value: str) -> str:
222
+ try:
223
+ host = (urlparse(value).hostname or "").lower()
224
+ except ValueError:
225
+ return ""
226
+ return host[4:] if host.startswith("www.") else host
227
+
228
+
229
+ def parse_search(content: bytes) -> List[dict]:
230
+ """Parse a search/list response into [{"id", "name", "doi"}]."""
231
+ root = _safe_parse(content)
232
+ if root is None:
233
+ return []
234
+ out: List[dict] = []
235
+ seen = set()
236
+ for node in root.iter():
237
+ if _local(node.tag) != "repository":
238
+ continue
239
+ rid = _first_text(node, "id")
240
+ if not ID_RE.match(rid) or rid in seen:
241
+ continue
242
+ seen.add(rid)
243
+ out.append({
244
+ "id": rid,
245
+ "name": _first_text(node, "name")[:300],
246
+ "doi": _first_text(node, "doi")[:200],
247
+ })
248
+ return out
249
+
250
+
251
+ def _yes_no_unknown(value: str) -> str:
252
+ value = (value or "").strip().lower()
253
+ if value == "yes":
254
+ return "Yes"
255
+ if value == "no":
256
+ return "No"
257
+ return "Unknown"
258
+
259
+
260
+ _SUBJECT_CODE = re.compile(r"^\s*\d+\s+")
261
+
262
+
263
+ def parse_detail(content: bytes) -> Optional[dict]:
264
+ """Parse one re3data repository record. Returns None if unusable."""
265
+ root = _safe_parse(content)
266
+ if root is None:
267
+ return None
268
+ repo = root if _local(root.tag) == "repository" else next(
269
+ (node for node in root.iter() if _local(node.tag) == "repository"), None
270
+ )
271
+ if repo is None:
272
+ return None
273
+ rid = _first_text(repo, "re3data.orgIdentifier")
274
+ name = _first_text(repo, "repositoryName")
275
+ if not ID_RE.match(rid) or not name:
276
+ return None
277
+
278
+ subjects: List[str] = []
279
+ for value in _all_text(repo, "subject", limit=30):
280
+ clean = _SUBJECT_CODE.sub("", value).strip()
281
+ if clean and clean not in subjects:
282
+ subjects.append(clean)
283
+
284
+ pid_systems = [p for p in _all_text(repo, "pidSystem", limit=8, width=40) if p.lower() != "none"]
285
+ upload_types = [v.lower() for v in _all_text(repo, "dataUploadType", limit=5, width=30)]
286
+ access_types = [v.lower() for v in _all_text(repo, "dataAccessType", limit=5, width=30)]
287
+ description = _first_text(repo, "description")
288
+ if len(description) > 600:
289
+ description = description[:597].rsplit(" ", 1)[0] + "…"
290
+
291
+ return {
292
+ "id": rid,
293
+ "name": name[:300],
294
+ "url": _https_url(_first_text(repo, "repositoryURL")),
295
+ "description": description,
296
+ "types": [t.lower() for t in _all_text(repo, "type", limit=5, width=40)],
297
+ "subjects": subjects[:10],
298
+ "keywords": _all_text(repo, "keyword", limit=15, width=80),
299
+ "content_types": [_SUBJECT_CODE.sub("", c) for c in _all_text(repo, "contentType", limit=10, width=120)],
300
+ "pid_systems": pid_systems,
301
+ "versioning": _yes_no_unknown(_first_text(repo, "versioning")),
302
+ "data_upload": upload_types,
303
+ "data_access": access_types,
304
+ "certificates": _all_text(repo, "certificate", limit=5, width=80),
305
+ "countries": _all_text(repo, "institutionCountry", limit=5, width=10),
306
+ "end_date": _first_text(repo, "endDate")[:30],
307
+ "last_update": _first_text(repo, "lastUpdate")[:30],
308
+ }
309
+
310
+
311
+ # ── Network operations ────────────────────────────────────────────────────
312
+
313
+ class Re3dataUnavailable(RuntimeError):
314
+ pass
315
+
316
+
317
+ async def _get(url: str, params: Optional[dict] = None) -> bytes:
318
+ global _last_success, _last_error
319
+ try:
320
+ response = await _client().get(url, params=params)
321
+ except (httpx.HTTPError, ValueError) as exc:
322
+ _last_error = {"type": type(exc).__name__, "at": _now_iso()}
323
+ raise Re3dataUnavailable() from None
324
+ if response.status_code != 200:
325
+ _last_error = {"status": response.status_code, "at": _now_iso()}
326
+ raise Re3dataUnavailable()
327
+ content = response.content
328
+ if len(content) > MAX_RESPONSE_BYTES:
329
+ _last_error = {"type": "ResponseTooLarge", "at": _now_iso()}
330
+ raise Re3dataUnavailable()
331
+ _last_success = _now_iso()
332
+ _last_error = None
333
+ return content
334
+
335
+
336
+ async def search(query: str) -> List[dict]:
337
+ """Search re3data; cached. Raises Re3dataUnavailable on failure."""
338
+ key = " ".join(query.lower().split())[:120]
339
+ cached = _cache_get(_search_cache, key)
340
+ if cached is not None:
341
+ value = cached[1]
342
+ if isinstance(value, Exception):
343
+ raise Re3dataUnavailable()
344
+ return value
345
+ pending = _inflight.get("s:" + key)
346
+ if pending is not None:
347
+ return await asyncio.shield(pending)
348
+
349
+ loop = asyncio.get_running_loop()
350
+ future: asyncio.Future = loop.create_future()
351
+ _inflight["s:" + key] = future
352
+ try:
353
+ content = await _get(SEARCH_URL, params={"query": key})
354
+ hits = parse_search(content)
355
+ _cache_put(_search_cache, key, hits, SEARCH_TTL, CACHE_MAX)
356
+ future.set_result(hits)
357
+ return hits
358
+ except Exception as exc:
359
+ _cache_put(_search_cache, key, Re3dataUnavailable(), FAILURE_TTL, CACHE_MAX)
360
+ future.set_exception(Re3dataUnavailable())
361
+ future.exception() # mark retrieved; concurrent waiters still see it
362
+ raise Re3dataUnavailable() from None
363
+ finally:
364
+ _inflight.pop("s:" + key, None)
365
+
366
+
367
+ async def detail(rid: str) -> Optional[dict]:
368
+ """Fetch one record; cached. Returns None when unavailable."""
369
+ if not ID_RE.match(rid or ""):
370
+ return None
371
+ cached = _cache_get(_detail_cache, rid)
372
+ if cached is not None:
373
+ return cached[1]
374
+ try:
375
+ content = await _get(DETAIL_URL.format(rid=rid))
376
+ except Re3dataUnavailable:
377
+ _cache_put(_detail_cache, rid, None, FAILURE_TTL, DETAIL_CACHE_MAX)
378
+ return None
379
+ record = parse_detail(content)
380
+ _cache_put(_detail_cache, rid, record, DETAIL_TTL if record else FAILURE_TTL, DETAIL_CACHE_MAX)
381
+ return record
382
+
383
+
384
+ # ── Selection ─────────────────────────────────────────────────────────────
385
+
386
+ def _tokens(text: str) -> List[str]:
387
+ out: List[str] = []
388
+ for token in re.findall(r"[a-z0-9]+", (text or "").lower()):
389
+ if len(token) > 2 and token not in _STOPWORDS and token not in out:
390
+ out.append(token)
391
+ return out
392
+
393
+
394
+ def _stem(token: str) -> str:
395
+ for suffix in ("ies", "es", "s"):
396
+ if token.endswith(suffix) and len(token) - len(suffix) >= 4:
397
+ return token[: -len(suffix)] + ("y" if suffix == "ies" else "")
398
+ return token
399
+
400
+
401
+ def _matches(tokens: Iterable[str], text: str) -> List[str]:
402
+ words = {_stem(w) for w in re.findall(r"[a-z0-9]+", (text or "").lower())}
403
+ return [t for t in tokens if _stem(t) in words]
404
+
405
+
406
+ def build_query(discipline: str) -> str:
407
+ """Only the user's discipline field is sent; bounded and normalised."""
408
+ text = " ".join(str(discipline or "").split())
409
+ text = re.sub(r"[^\w\s\-&,/()]", " ", text, flags=re.UNICODE)
410
+ return " ".join(text.split())[:100]
411
+
412
+
413
+ def _is_sensitive(constraints: dict) -> bool:
414
+ sensitivity = str(constraints.get("sensitivity") or "").strip().lower()
415
+ personal = str(constraints.get("contains_personal_data") or "").strip().lower()
416
+ return (bool(sensitivity) and sensitivity not in {"open", "none", "non-sensitive", "not sensitive"}) or (
417
+ bool(personal) and personal != "no"
418
+ )
419
+
420
+
421
+ def _excluded_by_registry(record: dict, curated: Iterable[dict]) -> bool:
422
+ name = " ".join(record.get("name", "").lower().split())
423
+ host = _host(record.get("url", ""))
424
+ for entry in curated:
425
+ entry_name = " ".join(str(entry.get("name", "")).lower().split())
426
+ if name and (name == entry_name or (len(entry_name) > 4 and entry_name in name)):
427
+ return True
428
+ entry_host = _host(str(entry.get("url", "")))
429
+ if host and entry_host and (host == entry_host or host.endswith("." + entry_host)):
430
+ return True
431
+ return False
432
+
433
+
434
+ def score_record(record: dict, tokens: List[str]) -> Tuple[int, List[str]]:
435
+ """Relevance from subject/keyword/name evidence plus deposit-readiness."""
436
+ subject_text = " ".join(record.get("subjects", []) + record.get("keywords", []) + [record.get("name", "")])
437
+ strong = _matches(tokens, subject_text)
438
+ weak = [t for t in _matches(tokens, record.get("description", "")) if t not in strong]
439
+ if not strong:
440
+ return 0, []
441
+ score = 10 * len(strong) + 3 * len(weak)
442
+ pids = {p.lower() for p in record.get("pid_systems", [])}
443
+ if "doi" in pids:
444
+ score += 4
445
+ if record.get("certificates"):
446
+ score += 3
447
+ if "open" in record.get("data_upload", []):
448
+ score += 2
449
+ if record.get("versioning") == "Yes":
450
+ score += 1
451
+ return score, strong + weak
452
+
453
+
454
+ def _public_entry(record: dict, matched: List[str], sensitive: bool) -> dict:
455
+ pids = record.get("pid_systems", [])
456
+ upload = record.get("data_upload", [])
457
+ access = record.get("data_access", [])
458
+ notes = [
459
+ "Listed in re3data.org; not reviewed by KU Library. Confirm deposit eligibility, costs, "
460
+ "licences and file limits on the repository's own site before uploading."
461
+ ]
462
+ if upload and "open" not in upload:
463
+ notes.append("re3data lists data upload as restricted; depositor eligibility must be confirmed.")
464
+ if sensitive:
465
+ notes.append(
466
+ "Your dataset is marked sensitive or personal: KU Library and the responsible ethics/data-protection "
467
+ "team must approve any repository and access conditions before upload."
468
+ )
469
+ last_update = record.get("last_update", "")
470
+ return {
471
+ "id": record["id"],
472
+ "name": record["name"],
473
+ "url": record.get("url", ""),
474
+ "re3data_url": RECORD_URL.format(rid=record["id"]),
475
+ "description": record.get("description", ""),
476
+ "repository_types": record.get("types", []),
477
+ "subjects": record.get("subjects", [])[:6],
478
+ "content_types": record.get("content_types", [])[:6],
479
+ "persistent_identifier": ", ".join(pids) if pids else "Not listed",
480
+ "doi_assignment": "Yes" if any(p.lower() == "doi" for p in pids) else "Not listed",
481
+ "versioning": record.get("versioning", "Unknown"),
482
+ "data_upload": ", ".join(u.capitalize() for u in upload) if upload else "Not listed",
483
+ "data_access": ", ".join(a.capitalize() for a in access) if access else "Not listed",
484
+ "certificates": record.get("certificates", []),
485
+ "countries": record.get("countries", []),
486
+ "last_update": last_update,
487
+ "matched_terms": matched[:6],
488
+ "source": "re3data",
489
+ "source_note": (
490
+ "Details as recorded by re3data.org"
491
+ + (f" (record last updated {last_update[:10]})" if last_update else "")
492
+ + "."
493
+ ),
494
+ "review_notes": notes,
495
+ "file_deposit_recommended": False,
496
+ }
497
+
498
+
499
+ async def find_supplementary(
500
+ discipline: str,
501
+ constraints: Optional[dict] = None,
502
+ curated: Iterable[dict] = (),
503
+ limit: int = 5,
504
+ ) -> dict:
505
+ """Return {"status", "query", "repositories", "message", "retrieved_at"}.
506
+
507
+ Never raises. ``curated`` is the KU registry, used only for de-duplication.
508
+ """
509
+ constraints = constraints if isinstance(constraints, dict) else {}
510
+ query = build_query(discipline)
511
+ base = {"status": "skipped", "query": query, "repositories": [], "message": "",
512
+ "source": "re3data.org", "source_url": "https://www.re3data.org",
513
+ "retrieved_at": _now_iso()}
514
+ if not enabled():
515
+ return {**base, "status": "disabled",
516
+ "message": "re3data search is switched off for this service."}
517
+ tokens = _tokens(query)
518
+ if not tokens:
519
+ return {**base, "message": "Enter a discipline to search re3data for additional repositories."}
520
+
521
+ curated = list(curated)
522
+ try:
523
+ hits = await search(query)
524
+ if len(hits) < 3 and len(tokens) > 1:
525
+ # A multi-word discipline can be too narrow for the registry's
526
+ # search; retry once with the most distinctive (longest) word.
527
+ extra = await search(max(tokens, key=len))
528
+ seen = {h["id"] for h in hits}
529
+ hits += [h for h in extra if h["id"] not in seen]
530
+ except Re3dataUnavailable:
531
+ return {**base, "status": "unavailable",
532
+ "message": "re3data.org could not be reached. The curated KU Library recommendations are unaffected."}
533
+ except Exception as exc: # defensive: never break the repository finder
534
+ logger.warning("[re3data] search failed (%s)", type(exc).__name__)
535
+ return {**base, "status": "unavailable",
536
+ "message": "re3data.org could not be searched. The curated KU Library recommendations are unaffected."}
537
+
538
+ if not hits:
539
+ return {**base, "status": "no_matches",
540
+ "message": f'No additional re3data repositories were found for "{query}".'}
541
+
542
+ # Fetch the hits most likely to be relevant first: name matches lead.
543
+ ordered = sorted(enumerate(hits), key=lambda item: (-len(_matches(tokens, item[1].get("name", ""))), item[0]))
544
+ candidates = [hit for _i, hit in ordered[:MAX_DETAIL_FETCHES]]
545
+ semaphore = asyncio.Semaphore(DETAIL_CONCURRENCY)
546
+
547
+ async def fetch(rid: str) -> Optional[dict]:
548
+ async with semaphore:
549
+ try:
550
+ return await detail(rid)
551
+ except Exception as exc: # defensive
552
+ logger.warning("[re3data] detail failed (%s)", type(exc).__name__)
553
+ return None
554
+
555
+ records = await asyncio.gather(*(fetch(hit["id"]) for hit in candidates))
556
+ sensitive = _is_sensitive(constraints)
557
+ scored: List[Tuple[int, str, dict, List[str]]] = []
558
+ for record in records:
559
+ if not record:
560
+ continue
561
+ if record.get("end_date"):
562
+ continue # closed repository
563
+ upload = record.get("data_upload", [])
564
+ if upload and all(u == "closed" for u in upload):
565
+ continue # does not accept deposits
566
+ types = set(record.get("types", []))
567
+ if types and types <= {"institutional"}:
568
+ continue # usually limited to that institution's own members
569
+ if _excluded_by_registry(record, curated):
570
+ continue
571
+ if sensitive:
572
+ access = set(record.get("data_access", []))
573
+ if not access & {"restricted", "embargoed", "closed"}:
574
+ continue # no controlled-access route recorded
575
+ score, matched = score_record(record, tokens)
576
+ if score <= 0:
577
+ continue
578
+ scored.append((score, record["name"].lower(), record, matched))
579
+
580
+ scored.sort(key=lambda item: (-item[0], item[1]))
581
+ picked = [_public_entry(record, matched, sensitive) for _s, _n, record, matched in scored[: max(0, limit)]]
582
+ if not picked:
583
+ return {**base, "status": "no_matches",
584
+ "message": f're3data returned results for "{query}", but none passed the subject and deposit checks.'}
585
+ return {**base, "status": "ok", "repositories": picked,
586
+ "message": (
587
+ f'{len(picked)} additional repositor{"y" if len(picked) == 1 else "ies"} from re3data.org matched "{query}". '
588
+ "These are registry listings, not KU Library recommendations."
589
+ )}
repositories.py CHANGED
@@ -1,708 +1,708 @@
1
- """
2
- repositories.py — curated registry of research data repositories.
3
-
4
- WHY THIS EXISTS
5
- ---------------
6
- The previous /api/find-repository asked the LLM to produce repository facts:
7
- max file size, certification, cost, persistent identifier type. Those are
8
- exactly the kind of specific, checkable claims a model gets subtly wrong, and
9
- a wrong "50 GB limit" or "CoreTrustSeal certified" in a library tool is a
10
- credibility problem.
11
-
12
- Here the LLM only SELECTS entries by id and writes a fit_reason. Every factual
13
- field below is served from this file and overwrites whatever the model says.
14
-
15
- MAINTENANCE
16
- -----------
17
- Each entry carries `source_url` and `verified` (YYYY-MM). Re-check quarterly.
18
- Fields you are unsure of should be left as "" rather than guessed — the UI
19
- renders empty fields as "Not specified", which is honest.
20
-
21
- Selection is deliberately KU-flavoured: engineering, energy, materials,
22
- petroleum, AI/CS and biomedical are weighted because that is KU's output
23
- profile.
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- from typing import Any
29
- import json
30
- import re
31
-
32
- # ── Registry ───────────────────────────────────────────────────────────────
33
- # NOTE: max_file_size / certification are left blank where not personally
34
- # verified. Fill them in from source_url and bump `verified`.
35
-
36
- REGISTRY: dict[str, dict[str, Any]] = {
37
- # ---- Institutional ----------------------------------------------------
38
- "khazna": {
39
- "id": "khazna",
40
- "name": "Khazna",
41
- "type": "Institutional",
42
- "url": "https://khazna.ku.ac.ae",
43
- "scope": "Khalifa University research outputs, datasets and theses across all disciplines.",
44
- "data_types_accepted": "Confirm file-deposit support with KU Library",
45
- "persistent_identifier": "",
46
- "versioning": "",
47
- "embargo_support": "",
48
- "cost": "Free for KU researchers",
49
- "curation": "Mediated — library review before publication",
50
- "source_url": "https://khazna.ku.ac.ae",
51
- "verified": "2026-07",
52
- "is_khazna": True,
53
- "disciplines": ["all"],
54
- },
55
-
56
- # ---- Generalist -------------------------------------------------------
57
- "zenodo": {
58
- "id": "zenodo", "name": "Zenodo", "type": "Generalist",
59
- "url": "https://zenodo.org",
60
- "scope": "Multidisciplinary repository operated by CERN; accepts data, software, publications.",
61
- "data_types_accepted": "All types", "persistent_identifier": "DOI",
62
- "versioning": "Yes", "embargo_support": "Yes", "cost": "Free",
63
- "curation": "Unmediated",
64
- "source_url": "https://help.zenodo.org", "verified": "2026-07",
65
- "disciplines": ["all"],
66
- },
67
- "figshare": {
68
- "id": "figshare", "name": "Figshare", "type": "Generalist",
69
- "url": "https://figshare.com",
70
- "scope": "Multidisciplinary repository for figures, datasets, media and preprints.",
71
- "data_types_accepted": "All types", "persistent_identifier": "DOI",
72
- "versioning": "Yes", "embargo_support": "Yes", "cost": "Free tier available",
73
- "curation": "Unmediated",
74
- "source_url": "https://help.figshare.com", "verified": "2026-07",
75
- "disciplines": ["all"],
76
- },
77
- "dryad": {
78
- "id": "dryad", "name": "Dryad", "type": "Generalist",
79
- "url": "https://datadryad.org",
80
- "scope": "Curated repository for data underlying published research, strong in life and environmental sciences.",
81
- "data_types_accepted": "Research data (not software)", "persistent_identifier": "DOI",
82
- "versioning": "Yes", "embargo_support": "Yes",
83
- "cost": "Fee-based; free where the institution is a member",
84
- "curation": "Mediated — curator review",
85
- "source_url": "https://datadryad.org/stash/faq", "verified": "2026-07",
86
- "disciplines": ["life sciences", "environmental", "ecology", "biology"],
87
- },
88
- "dataverse": {
89
- "id": "dataverse", "name": "Harvard Dataverse", "type": "Generalist",
90
- "url": "https://dataverse.harvard.edu",
91
- "scope": "Multidisciplinary repository with strong social science and statistical tooling.",
92
- "data_types_accepted": "All types", "persistent_identifier": "DOI",
93
- "versioning": "Yes", "embargo_support": "Yes", "cost": "Free",
94
- "curation": "Unmediated",
95
- "source_url": "https://support.dataverse.harvard.edu", "verified": "2026-07",
96
- "disciplines": ["social sciences", "economics", "political science", "all"],
97
- },
98
- "osf": {
99
- "id": "osf", "name": "Open Science Framework (OSF)", "type": "Generalist",
100
- "url": "https://osf.io",
101
- "scope": "Project-based platform combining data, preprints, preregistration and materials.",
102
- "data_types_accepted": "All types", "persistent_identifier": "DOI",
103
- "versioning": "Yes", "embargo_support": "Yes", "cost": "Free",
104
- "curation": "Unmediated",
105
- "source_url": "https://help.osf.io", "verified": "2026-07",
106
- "disciplines": ["psychology", "social sciences", "all"],
107
- },
108
- "mendeley_data": {
109
- "id": "mendeley_data", "name": "Mendeley Data", "type": "Generalist",
110
- "url": "https://data.mendeley.com",
111
- "scope": "Multidisciplinary data repository operated by Elsevier.",
112
- "data_types_accepted": "All types", "persistent_identifier": "DOI",
113
- "versioning": "Yes", "embargo_support": "Yes", "cost": "Free tier available",
114
- "curation": "Unmediated",
115
- "source_url": "https://data.mendeley.com", "verified": "2026-07",
116
- "disciplines": ["all"],
117
- },
118
-
119
- # ---- Engineering / energy / materials — KU core ------------------------
120
- "materials_cloud": {
121
- "id": "materials_cloud", "name": "Materials Cloud", "type": "Domain-specific",
122
- "url": "https://www.materialscloud.org",
123
- "scope": "Computational materials science data, workflows and simulations.",
124
- "data_types_accepted": "Simulation data, structures, workflows",
125
- "persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
126
- "cost": "Free", "curation": "Mediated",
127
- "source_url": "https://www.materialscloud.org/home", "verified": "2026-07",
128
- "disciplines": ["materials", "chemistry", "physics", "computational"],
129
- },
130
- "nomad": {
131
- "id": "nomad", "name": "NOMAD", "type": "Domain-specific",
132
- "url": "https://nomad-lab.eu",
133
- "scope": "Repository for computational materials science, especially DFT calculations.",
134
- "data_types_accepted": "Electronic-structure calculation outputs",
135
- "persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
136
- "cost": "Free", "curation": "Unmediated",
137
- "source_url": "https://nomad-lab.eu", "verified": "2026-07",
138
- "disciplines": ["materials", "physics", "chemistry", "computational"],
139
- },
140
- "oedi": {
141
- "id": "oedi", "name": "Open Energy Data Initiative (OEDI)", "type": "Domain-specific",
142
- "url": "https://data.openei.org",
143
- "scope": "Energy systems, renewables, grid and buildings data (US DOE).",
144
- "data_types_accepted": "Energy datasets, time series, geospatial",
145
- "persistent_identifier": "DOI", "versioning": "", "embargo_support": "",
146
- "cost": "Free", "curation": "Mediated",
147
- "source_url": "https://data.openei.org", "verified": "2026-07",
148
- "disciplines": ["energy", "renewables", "engineering", "sustainability"],
149
- },
150
- "ieee_dataport": {
151
- "id": "ieee_dataport", "name": "IEEE DataPort", "type": "Domain-specific",
152
- "url": "https://ieee-dataport.org",
153
- "scope": "Datasets across electrical engineering, computing, communications and signal processing.",
154
- "data_types_accepted": "All types", "persistent_identifier": "DOI",
155
- "versioning": "Yes", "embargo_support": "Yes",
156
- "cost": "Free for open datasets; subscription for some features",
157
- "curation": "Unmediated",
158
- "source_url": "https://ieee-dataport.org", "verified": "2026-07",
159
- "disciplines": ["engineering", "electrical", "computing", "communications", "signal processing"],
160
- },
161
-
162
- # ---- Earth / environment / geospatial ---------------------------------
163
- "pangaea": {
164
- "id": "pangaea", "name": "PANGAEA", "type": "Domain-specific",
165
- "url": "https://www.pangaea.de",
166
- "scope": "Earth and environmental science data; georeferenced observations.",
167
- "data_types_accepted": "Georeferenced environmental data",
168
- "persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
169
- "cost": "Free", "curation": "Mediated — editorial review",
170
- "source_url": "https://www.pangaea.de/submit/", "verified": "2026-07",
171
- "disciplines": ["earth science", "environmental", "climate", "oceanography", "geoscience"],
172
- },
173
- "esa_earth": {
174
- "id": "esa_earth", "name": "ESA Earth Online", "type": "Domain-specific",
175
- "url": "https://earth.esa.int",
176
- "scope": "Earth observation and satellite remote-sensing datasets.",
177
- "data_types_accepted": "Satellite imagery, EO products",
178
- "persistent_identifier": "", "versioning": "", "embargo_support": "",
179
- "cost": "Free", "curation": "Mediated",
180
- "source_url": "https://earth.esa.int", "verified": "2026-07",
181
- "disciplines": ["remote sensing", "earth observation", "geospatial", "climate"],
182
- },
183
-
184
- # ---- Life sciences / biomedical ---------------------------------------
185
- "ena": {
186
- "id": "ena", "name": "European Nucleotide Archive (ENA)", "type": "Domain-specific",
187
- "url": "https://www.ebi.ac.uk/ena",
188
- "scope": "Nucleotide sequencing data — raw reads, assemblies, annotations.",
189
- "data_types_accepted": "Sequence data (FASTQ, BAM, assemblies)",
190
- "persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
191
- "cost": "Free", "curation": "Mediated — format validation",
192
- "source_url": "https://www.ebi.ac.uk/ena/browser/submit", "verified": "2026-07",
193
- "disciplines": ["genomics", "biology", "life sciences", "sequencing", "microbiology"],
194
- },
195
- "geo": {
196
- "id": "geo", "name": "NCBI GEO", "type": "Domain-specific",
197
- "url": "https://www.ncbi.nlm.nih.gov/geo",
198
- "scope": "Functional genomics data — gene expression, microarray, high-throughput sequencing.",
199
- "data_types_accepted": "Expression and functional genomics data",
200
- "persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
201
- "cost": "Free", "curation": "Mediated",
202
- "source_url": "https://www.ncbi.nlm.nih.gov/geo/info/submission.html", "verified": "2026-07",
203
- "disciplines": ["genomics", "transcriptomics", "biology", "life sciences"],
204
- },
205
- "pride": {
206
- "id": "pride", "name": "PRIDE", "type": "Domain-specific",
207
- "url": "https://www.ebi.ac.uk/pride",
208
- "scope": "Mass-spectrometry proteomics data.",
209
- "data_types_accepted": "Proteomics MS data",
210
- "persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
211
- "cost": "Free", "curation": "Mediated",
212
- "source_url": "https://www.ebi.ac.uk/pride/markdownpage/submitdatapage", "verified": "2026-07",
213
- "disciplines": ["proteomics", "biochemistry", "life sciences"],
214
- },
215
- "pdb": {
216
- "id": "pdb", "name": "Protein Data Bank (PDB)", "type": "Domain-specific",
217
- "url": "https://www.rcsb.org",
218
- "scope": "Three-dimensional structural data for biological macromolecules.",
219
- "data_types_accepted": "Macromolecular structures",
220
- "persistent_identifier": "PDB ID", "versioning": "Yes", "embargo_support": "Yes",
221
- "cost": "Free", "curation": "Mediated — biocuration",
222
- "source_url": "https://www.rcsb.org/pages/policy", "verified": "2026-07",
223
- "disciplines": ["structural biology", "biochemistry", "chemistry", "life sciences"],
224
- },
225
- "metabolights": {
226
- "id": "metabolights", "name": "MetaboLights", "type": "Domain-specific",
227
- "url": "https://www.ebi.ac.uk/metabolights",
228
- "scope": "Metabolomics experiments and derived information.",
229
- "data_types_accepted": "Metabolomics data",
230
- "persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
231
- "cost": "Free", "curation": "Mediated",
232
- "source_url": "https://www.ebi.ac.uk/metabolights", "verified": "2026-07",
233
- "disciplines": ["metabolomics", "biochemistry", "life sciences"],
234
- },
235
-
236
- # ---- Chemistry --------------------------------------------------------
237
- "chemrxiv_data": {
238
- "id": "chemrxiv_data", "name": "Cambridge Structural Database (CCDC)",
239
- "type": "Domain-specific", "url": "https://www.ccdc.cam.ac.uk",
240
- "scope": "Small-molecule organic and metal-organic crystal structures.",
241
- "data_types_accepted": "Crystallographic data (CIF)",
242
- "persistent_identifier": "CCDC number", "versioning": "", "embargo_support": "Yes",
243
- "cost": "Free deposition", "curation": "Mediated",
244
- "source_url": "https://www.ccdc.cam.ac.uk/structures/", "verified": "2026-07",
245
- "disciplines": ["chemistry", "crystallography", "materials"],
246
- },
247
-
248
- # ---- Computing / AI ---------------------------------------------------
249
- "huggingface_datasets": {
250
- "id": "huggingface_datasets", "name": "Hugging Face Datasets",
251
- "type": "Domain-specific", "url": "https://huggingface.co/datasets",
252
- "scope": "Machine learning datasets and models with versioned git-based storage.",
253
- "data_types_accepted": "ML datasets, models",
254
- "persistent_identifier": "", "versioning": "Yes", "embargo_support": "",
255
- "cost": "Free tier available", "curation": "Unmediated",
256
- "source_url": "https://huggingface.co/docs/hub/datasets", "verified": "2026-07",
257
- "disciplines": ["machine learning", "ai", "nlp", "computer vision", "computing"],
258
- },
259
- "openml": {
260
- "id": "openml", "name": "OpenML", "type": "Domain-specific",
261
- "url": "https://www.openml.org",
262
- "scope": "Machine learning datasets, tasks, flows and benchmark runs.",
263
- "data_types_accepted": "Tabular ML datasets",
264
- "persistent_identifier": "", "versioning": "Yes", "embargo_support": "",
265
- "cost": "Free", "curation": "Unmediated",
266
- "source_url": "https://docs.openml.org", "verified": "2026-07",
267
- "disciplines": ["machine learning", "ai", "data science", "computing"],
268
- },
269
-
270
- # ---- Social science / humanities --------------------------------------
271
- "ukds": {
272
- "id": "ukds", "name": "UK Data Service ReShare", "type": "Domain-specific",
273
- "url": "https://reshare.ukdataservice.ac.uk",
274
- "scope": "Social and economic research data, including sensitive data under controlled access.",
275
- "data_types_accepted": "Social science data",
276
- "persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
277
- "cost": "Free", "curation": "Mediated",
278
- "source_url": "https://reshare.ukdataservice.ac.uk", "verified": "2026-07",
279
- "disciplines": ["social sciences", "economics", "sociology", "survey"],
280
- },
281
- "icpsr": {
282
- "id": "icpsr", "name": "ICPSR", "type": "Domain-specific",
283
- "url": "https://www.icpsr.umich.edu",
284
- "scope": "Social and behavioural science data with strong curation and restricted-access options.",
285
- "data_types_accepted": "Social science data",
286
- "persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
287
- "cost": "Varies by membership", "curation": "Mediated — extensive curation",
288
- "source_url": "https://www.icpsr.umich.edu/web/pages/deposit/", "verified": "2026-07",
289
- "disciplines": ["social sciences", "behavioural", "health", "survey"],
290
- },
291
-
292
- # ---- Sensitive / controlled access -------------------------------------
293
- "ega": {
294
- "id": "ega", "name": "European Genome-phenome Archive (EGA)",
295
- "type": "Domain-specific", "url": "https://ega-archive.org",
296
- "scope": "Controlled-access repository for personally identifiable genetic and phenotypic data.",
297
- "data_types_accepted": "Human genomic and phenotypic data",
298
- "persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
299
- "cost": "Free", "curation": "Mediated — DAC-controlled access",
300
- "source_url": "https://ega-archive.org/submission", "verified": "2026-07",
301
- "disciplines": ["human genomics", "clinical", "biomedical", "sensitive"],
302
- },
303
- }
304
-
305
-
306
- # ── Helpers ────────────────────────────────────────────────────────────────
307
- # The model may author ONLY analytical fit fields. Every other repository
308
- # attribute is supplied from REGISTRY, including the id itself after lookup.
309
- MODEL_ALLOWED = ("fit_reason", "fit_confidence")
310
-
311
-
312
- def _clean_text(value: Any, limit: int = 1000) -> str:
313
- return " ".join(str(value or "").split())[:limit]
314
-
315
-
316
- def _clean_list(value: Any, limit: int = 12, item_limit: int = 500) -> list[str]:
317
- if not isinstance(value, list):
318
- return []
319
- out: list[str] = []
320
- for item in value[:limit]:
321
- cleaned = _clean_text(item, item_limit)
322
- if cleaned:
323
- out.append(cleaned)
324
- return out
325
-
326
-
327
- def _yes_no(value: Any) -> str:
328
- text = _clean_text(value, 100).casefold()
329
- if text in {"yes", "true"} or text.startswith("yes ") or text.startswith("yes—"):
330
- return "Yes"
331
- if text in {"no", "false"}:
332
- return "No"
333
- return "Unknown"
334
-
335
-
336
- def repository_capabilities(entry: dict) -> dict:
337
- """Expose only capabilities supported by existing curated fields.
338
-
339
- A generic homepage and a broad access description do not establish approval
340
- for a particular sensitive dataset, size limit or accepted file format.
341
- """
342
- identifier = _clean_text(entry.get("persistent_identifier"), 100)
343
- description = (str(entry.get("scope", "")) + " " + str(entry.get("curation", ""))).casefold()
344
- controlled = any(term in description for term in ("controlled access", "controlled-access", "restricted-access", "restricted access"))
345
- return {
346
- "doi": "Yes" if identifier.casefold() == "doi" else "No" if identifier else "Unknown",
347
- "persistent_id_type": identifier or "Unknown",
348
- "versioning": _yes_no(entry.get("versioning")),
349
- "embargo": _yes_no(entry.get("embargo_support")),
350
- "restricted_access": "Yes" if controlled and not entry.get("is_khazna") else "Unknown",
351
- "approved_sensitive_data": "Unknown",
352
- "accepts_external_deposits": "Unknown",
353
- "accepted_file_formats": "Unknown",
354
- "maximum_dataset_size": "Unknown",
355
- "source_url": entry.get("source_url", ""),
356
- "reviewed": entry.get("verified", ""),
357
- }
358
-
359
-
360
- def evaluate_repository(entry: dict, constraints: dict | None = None) -> dict:
361
- """Check stated requirements against curated facts; unknown is not Yes."""
362
- constraints = constraints if isinstance(constraints, dict) else {}
363
- capabilities = repository_capabilities(entry)
364
- unresolved, conflicts = [], []
365
- sensitivity = _clean_text(constraints.get("sensitivity"), 100).casefold()
366
- personal = _clean_text(constraints.get("contains_personal_data"), 100).casefold()
367
- sensitive = (bool(sensitivity) and sensitivity not in {"open", "none", "non-sensitive", "not sensitive"}) or (bool(personal) and personal != "no")
368
- for requirement, capability, label in (
369
- ("needs_doi", "doi", "DOI assignment"),
370
- ("needs_versioning", "versioning", "versioning"),
371
- ("embargo_required", "embargo", "an embargo"),
372
- ):
373
- if _yes_no(constraints.get(requirement)) != "Yes":
374
- continue
375
- if capabilities[capability] == "No":
376
- conflicts.append(f"The curated record does not support the requested {label}.")
377
- elif capabilities[capability] != "Yes":
378
- unresolved.append(f"Confirm support for {label} with the repository.")
379
- if sensitive:
380
- if capabilities["restricted_access"] != "Yes":
381
- conflicts.append("Controlled/restricted access suitable for the submitted sensitive or personal data is not established.")
382
- unresolved.append("KU Library and the responsible ethics/data-protection team must approve the repository and access conditions for this dataset before any file upload.")
383
- if _clean_text(constraints.get("file_formats")):
384
- unresolved.append("Confirm acceptance of the submitted file formats: " + _clean_text(constraints["file_formats"], 300))
385
- if _clean_text(constraints.get("approx_size")):
386
- unresolved.append("Confirm the size/quota and any associated cost for: " + _clean_text(constraints["approx_size"], 300))
387
- if _clean_text(constraints.get("funder")):
388
- unresolved.append("Confirm the funder's repository, licence and retention requirements; the registry does not establish mandate compliance.")
389
- if _clean_text(constraints.get("licence_intent")):
390
- unresolved.append("Confirm that the repository supports the selected licence and that you hold the necessary rights.")
391
- institution = _clean_text(constraints.get("institution"))
392
- country = _clean_text(constraints.get("country"))
393
- if institution or country:
394
- unresolved.append("Confirm depositor eligibility and any institution/country restrictions.")
395
- if _yes_no(constraints.get("embargo_required")) == "Yes":
396
- duration = constraints.get("embargo_months")
397
- if duration is not None:
398
- unresolved.append(f"Confirm support for the requested {duration}-month embargo.")
399
- elif constraints.get("embargo_duration"):
400
- unresolved.append("Confirm support for the requested embargo duration: " + _clean_text(constraints["embargo_duration"], 100))
401
- if entry.get("id") == "dryad" and "software" in _clean_text(constraints.get("data_types")).casefold():
402
- conflicts.append("The curated record lists research data, not software, as accepted content.")
403
- metadata_only = bool(entry.get("is_khazna"))
404
- if metadata_only:
405
- unresolved.insert(0, "Ask KU Library about metadata registration; dataset file acceptance, DOI assignment and access controls require confirmation.")
406
- # Institutional registration advice is not a file-deposit destination.
407
- conflicts = []
408
- return {
409
- "state": "metadata_only" if metadata_only else "does_not_meet" if conflicts else "conditional" if unresolved else "meets",
410
- "unresolved_constraints": list(dict.fromkeys(unresolved)),
411
- "constraint_conflicts": list(dict.fromkeys(conflicts)),
412
- "sensitive_data_review_required": sensitive,
413
- "file_deposit_recommended": not metadata_only and not conflicts and not unresolved,
414
- "strategy_eligible": not metadata_only and not conflicts and not sensitive,
415
- "capabilities": capabilities,
416
- }
417
-
418
-
419
- def registry_for_prompt(limit: int = 0) -> str:
420
- """Compact registry listing for the model to select from."""
421
- lines = []
422
- for r in REGISTRY.values():
423
- lines.append(
424
- f'- id="{r["id"]}" | {r["name"]} | {r["type"]} | '
425
- f'disciplines: {", ".join(r["disciplines"])} | {r["scope"]} | '
426
- f'capabilities: {json.dumps(repository_capabilities(r), ensure_ascii=False)} | '
427
- f'accepted content: {r.get("data_types_accepted", "Unknown")} | cost: {r.get("cost", "Unknown")}'
428
- )
429
- if limit and len(lines) >= limit:
430
- break
431
- return "\n".join(lines)
432
-
433
-
434
- def hydrate_selection(selected: list[dict], constraints: dict | None = None) -> list[dict]:
435
- """
436
- Hydrate exact registry ids and discard every model-authored factual field.
437
-
438
- Only `fit_reason` and `fit_confidence` survive from the model. Unknown ids,
439
- duplicate ids, non-object entries, and malformed confidence values are
440
- dropped or normalised rather than displayed as repository facts.
441
- """
442
- out: list[dict] = []
443
- seen: set[str] = set()
444
- if not isinstance(selected, list):
445
- return out
446
-
447
- for item in selected:
448
- if not isinstance(item, dict):
449
- continue
450
- rid = str(item.get("id", "")).strip().lower()
451
- entry = REGISTRY.get(rid)
452
- if not entry or rid in seen:
453
- continue
454
- seen.add(rid)
455
- assessment = evaluate_repository(entry, constraints)
456
- if constraints is not None and assessment["state"] == "does_not_meet":
457
- continue
458
-
459
- fit_reason = _clean_text(item.get("fit_reason"), 1200)
460
- confidence = _clean_text(item.get("fit_confidence"), 20).title()
461
- if confidence not in {"High", "Medium", "Low"}:
462
- confidence = ""
463
-
464
- merged = dict(entry)
465
- merged["fit_reason"] = fit_reason
466
- merged["fit_confidence"] = confidence
467
- # Backwards-compatible alias for older frontends; it is still an
468
- # analytical fit score, never a repository-fact confidence score.
469
- merged["confidence"] = confidence
470
- merged["verification_status"] = "Curated KU Library registry record"
471
- merged["verified_note"] = (
472
- f"Repository details verified {entry['verified']} from {entry['source_url']}"
473
- )
474
- merged["capabilities"] = assessment["capabilities"]
475
- merged["constraint_assessment"] = assessment
476
- merged["file_deposit_recommended"] = assessment["file_deposit_recommended"]
477
- merged["metadata_registration_only"] = bool(entry.get("is_khazna"))
478
- if constraints is not None and assessment["state"] == "conditional":
479
- merged["fit_reason"] = ("Candidate for review. " + fit_reason + " " + " ".join(assessment["unresolved_constraints"]))[:3000]
480
- merged["fit_confidence"] = "Low" if assessment["sensitive_data_review_required"] else "Medium"
481
- merged["confidence"] = merged["fit_confidence"]
482
- out.append(merged)
483
- return out
484
-
485
-
486
- def selection_exclusions(selected: list[dict], constraints: dict | None = None) -> list[dict]:
487
- """Explain deterministic exclusions without trusting model-authored facts."""
488
- excluded, seen = [], set()
489
- for item in selected if isinstance(selected, list) else []:
490
- if not isinstance(item, dict):
491
- continue
492
- rid = _clean_text(item.get("id"), 100).lower()
493
- entry = REGISTRY.get(rid)
494
- if not entry or rid in seen:
495
- continue
496
- seen.add(rid)
497
- result = evaluate_repository(entry, constraints)
498
- if result["state"] == "does_not_meet":
499
- excluded.append({"id": rid, "name": entry["name"], "reasons": result["constraint_conflicts"],
500
- "unresolved_constraints": result["unresolved_constraints"]})
501
- return excluded
502
-
503
-
504
- def hydrate_deposit_strategy(raw: Any, hydrated: list[dict], constraints: dict | None = None) -> dict:
505
- """Resolve model-selected ids to server-owned names and bound free text."""
506
- raw = raw if isinstance(raw, dict) else {}
507
- by_id = {str(r.get("id", "")): r for r in hydrated if r.get("id")}
508
- eligible = {rid for rid, entry in by_id.items()
509
- if not entry.get("is_khazna") and entry.get("constraint_assessment", {}).get("strategy_eligible", True)}
510
- ranked_ids = [
511
- str(r.get("id")) for r in hydrated
512
- if r.get("id") in eligible
513
- ]
514
-
515
- def selected_id(key: str, fallback_index: int) -> str:
516
- candidate = str(raw.get(key, "")).strip().lower()
517
- if candidate in eligible:
518
- return candidate
519
- return ranked_ids[fallback_index] if len(ranked_ids) > fallback_index else ""
520
-
521
- primary_id = selected_id("primary_repository_id", 0)
522
- secondary_id = selected_id("secondary_repository_id", 1)
523
- if secondary_id == primary_id:
524
- secondary_id = ranked_ids[1] if len(ranked_ids) > 1 else ""
525
-
526
- checks = _clean_list(raw.get("manual_checks_required"), limit=10)
527
- for entry in hydrated:
528
- checks.extend(entry.get("constraint_assessment", {}).get("unresolved_constraints", []))
529
- if not primary_id:
530
- checks.insert(0, "No file-deposit destination has been established for these requirements; contact KU Library before uploading data.")
531
- workflow = _clean_list(raw.get("deposit_workflow"), limit=10)
532
- if constraints is not None:
533
- # Model prose must not instruct immediate upload while deterministic
534
- # requirements remain unresolved, especially for sensitive datasets.
535
- workflow = (["Review the candidate repository's source guidance and all unresolved requirements.",
536
- "Confirm rights, consent, access conditions and depositor eligibility.",
537
- "Prepare metadata and supporting documentation; upload files only after all checks are complete."]
538
- if primary_id else ["Contact KU Library to identify an approved deposit or metadata-registration route.",
539
- "Do not upload dataset files until a suitable destination and access conditions are confirmed."])
540
- return {
541
- "primary_repository_id": primary_id,
542
- "primary_url": REGISTRY.get(primary_id, {}).get("url"),
543
- "secondary_url": REGISTRY.get(secondary_id, {}).get("url"),
544
- "secondary_repository_id": secondary_id,
545
- "primary_choice": by_id.get(primary_id, {}).get("name", "Not determined"),
546
- "secondary_choice": by_id.get(secondary_id, {}).get("name", "Not determined"),
547
- "metadata_standard": _clean_text(
548
- raw.get("metadata_standard") or "Check the selected repository's current metadata guidance.",
549
- 500,
550
- ),
551
- "deposit_workflow": workflow,
552
- "manual_checks_required": list(dict.fromkeys(checks)),
553
- "selection_status": "candidates_require_review" if checks and primary_id else "no_verified_destination" if not primary_id else "requirements_matched",
554
- "khazna_metadata_note": (
555
- "Ask KU Library about registering dataset metadata in Khazna when the files are held in an appropriate external repository."
556
- ),
557
- }
558
-
559
-
560
- def khazna_entry(constraints: dict | None = None) -> dict:
561
- e = dict(REGISTRY["khazna"])
562
- e["fit_reason"] = ("Contact KU Library about registering dataset metadata in Khazna for institutional visibility. "
563
- "File-deposit eligibility, DOI assignment and access controls must be confirmed separately.")
564
- e["metadata_registration_only"] = True
565
- e["file_deposit_recommended"] = False
566
- e["constraint_assessment"] = evaluate_repository(e, constraints)
567
- e["capabilities"] = e["constraint_assessment"]["capabilities"]
568
- e["fit_confidence"] = "High"
569
- e["confidence"] = "High"
570
- e["verification_status"] = "Curated KU Library registry record"
571
- e["verified_note"] = (f"Repository details verified {e['verified']} "
572
- f"from {e['source_url']}")
573
- return e
574
-
575
-
576
- # ══════════════════════════════════════════════════════════════════════════
577
- # SUBJECT REPOSITORIES — for ARTICLES, not datasets
578
- # ══════════════════════════════════════════════════════════════════════════
579
- # OA.Works returns permitted locations such as "Non-commercial Subject
580
- # Repository". That string alone is not actionable: the author still has to
581
- # know WHICH one. These entries are matched against the journal's SCImago
582
- # subject areas/categories so the answer is specific.
583
- #
584
- # Deliberately distinct from REGISTRY above, which holds DATA repositories.
585
- # Depositing a manuscript in Dryad or a dataset in arXiv are both wrong.
586
-
587
- SUBJECT_REPOSITORIES: dict[str, dict] = {
588
- "arxiv": {
589
- "id": "arxiv", "name": "arXiv", "url": "https://arxiv.org",
590
- "scope": "Physics, mathematics, computer science, quantitative biology, "
591
- "statistics, electrical engineering and economics.",
592
- "versions": "Preprint and accepted manuscript",
593
- "moderation": "Moderated; endorsement required for first submission",
594
- "source_url": "https://arxiv.org/help/submit", "verified": "2026-07",
595
- "match": ["physics", "astronomy", "mathematics", "computer science",
596
- "engineering", "electrical", "statistics", "economics",
597
- "materials science", "energy"],
598
- },
599
- "pmc": {
600
- "id": "pmc", "name": "Europe PMC / PubMed Central",
601
- "url": "https://europepmc.org",
602
- "scope": "Biomedical and life sciences literature. Required by many "
603
- "health funders.",
604
- "versions": "Accepted manuscript and published version where licensed",
605
- "moderation": "Mediated; funder-linked deposit routes available",
606
- "source_url": "https://europepmc.org/Help", "verified": "2026-07",
607
- "match": ["medicine", "biochemistry", "immunology", "microbiology",
608
- "neuroscience", "pharmacology", "health", "nursing",
609
- "veterinary", "dentistry", "biology"],
610
- },
611
- "biorxiv": {
612
- "id": "biorxiv", "name": "bioRxiv", "url": "https://www.biorxiv.org",
613
- "scope": "Preprints across the biological sciences.",
614
- "versions": "Preprint (submitted version) only",
615
- "moderation": "Screened, not peer reviewed",
616
- "source_url": "https://www.biorxiv.org/about/FAQ", "verified": "2026-07",
617
- "match": ["biology", "biochemistry", "genetics", "neuroscience",
618
- "immunology", "microbiology", "agricultural"],
619
- },
620
- "chemrxiv": {
621
- "id": "chemrxiv", "name": "ChemRxiv", "url": "https://chemrxiv.org",
622
- "scope": "Preprints in chemistry and related areas.",
623
- "versions": "Preprint (submitted version) only",
624
- "moderation": "Screened, not peer reviewed",
625
- "source_url": "https://chemrxiv.org/engage/chemrxiv/public-dashboard",
626
- "verified": "2026-07",
627
- "match": ["chemistry", "chemical engineering", "materials science"],
628
- },
629
- "engrxiv": {
630
- "id": "engrxiv", "name": "engrXiv", "url": "https://engrxiv.org",
631
- "scope": "Preprints across all engineering disciplines.",
632
- "versions": "Preprint and accepted manuscript",
633
- "moderation": "Screened, not peer reviewed",
634
- "source_url": "https://engrxiv.org", "verified": "2026-07",
635
- "match": ["engineering", "civil", "mechanical", "aerospace",
636
- "industrial", "environmental engineering"],
637
- },
638
- "ssrn": {
639
- "id": "ssrn", "name": "SSRN", "url": "https://www.ssrn.com",
640
- "scope": "Social sciences, economics, management, law and humanities.",
641
- "versions": "Preprint and accepted manuscript",
642
- "moderation": "Screened",
643
- "source_url": "https://www.ssrn.com/index.cfm/en/", "verified": "2026-07",
644
- "match": ["social sciences", "economics", "business", "management",
645
- "accounting", "finance", "law", "psychology", "decision sciences"],
646
- },
647
- "repec": {
648
- "id": "repec", "name": "RePEc", "url": "http://repec.org",
649
- "scope": "Working papers and articles in economics and finance.",
650
- "versions": "Working paper and accepted manuscript",
651
- "moderation": "Institution-mediated archives",
652
- "source_url": "http://repec.org/", "verified": "2026-07",
653
- "match": ["economics", "econometrics", "finance"],
654
- },
655
- "eartharxiv": {
656
- "id": "eartharxiv", "name": "EarthArXiv",
657
- "url": "https://eartharxiv.org",
658
- "scope": "Preprints in earth, planetary and environmental sciences.",
659
- "versions": "Preprint and accepted manuscript",
660
- "moderation": "Screened, not peer reviewed",
661
- "source_url": "https://eartharxiv.org", "verified": "2026-07",
662
- "match": ["earth", "planetary", "environmental science", "geology",
663
- "atmospheric", "oceanography"],
664
- },
665
- "medrxiv": {
666
- "id": "medrxiv", "name": "medRxiv", "url": "https://www.medrxiv.org",
667
- "scope": "Preprints in clinical and health sciences.",
668
- "versions": "Preprint (submitted version) only",
669
- "moderation": "Screened; clinical claims reviewed",
670
- "source_url": "https://www.medrxiv.org/about/FAQ", "verified": "2026-07",
671
- "match": ["medicine", "health", "clinical", "epidemiology", "public health"],
672
- },
673
- }
674
-
675
- # Always offered when a subject repository is permitted but nothing matched.
676
- SUBJECT_FALLBACK = ["arxiv", "ssrn"]
677
-
678
-
679
- def suggest_subject_repositories(areas: str = "", categories: str = "",
680
- limit: int = 3) -> list[dict]:
681
- """
682
- Match a journal's SCImago subject areas/categories to subject repositories.
683
-
684
- Deterministic keyword scoring — no LLM. Returns [] rather than guessing
685
- when nothing matches well and no fallback is warranted.
686
- """
687
- haystack = f"{areas} {categories}".lower()
688
- if not haystack.strip():
689
- return []
690
-
691
- scored: list[tuple[int, dict]] = []
692
- for entry in SUBJECT_REPOSITORIES.values():
693
- score = sum(1 for kw in entry["match"] if kw in haystack)
694
- if score:
695
- scored.append((score, entry))
696
-
697
- scored.sort(key=lambda x: (-x[0], x[1]["name"]))
698
- picked = [dict(e) for _, e in scored[:limit]]
699
-
700
- # An unrelated fallback is not a subject match. Return no suggestions if
701
- # the supplied discipline does not match a curated repository.
702
- if not picked:
703
- return []
704
-
705
- for p in picked:
706
- p.pop("match", None)
707
- p["verified_note"] = f"Verified {p['verified']} from {p['source_url']}"
708
- return picked
 
1
+ """
2
+ repositories.py — curated registry of research data repositories.
3
+
4
+ WHY THIS EXISTS
5
+ ---------------
6
+ The previous /api/find-repository asked the LLM to produce repository facts:
7
+ max file size, certification, cost, persistent identifier type. Those are
8
+ exactly the kind of specific, checkable claims a model gets subtly wrong, and
9
+ a wrong "50 GB limit" or "CoreTrustSeal certified" in a library tool is a
10
+ credibility problem.
11
+
12
+ Here the LLM only SELECTS entries by id and writes a fit_reason. Every factual
13
+ field below is served from this file and overwrites whatever the model says.
14
+
15
+ MAINTENANCE
16
+ -----------
17
+ Each entry carries `source_url` and `verified` (YYYY-MM). Re-check quarterly.
18
+ Fields you are unsure of should be left as "" rather than guessed — the UI
19
+ renders empty fields as "Not specified", which is honest.
20
+
21
+ Selection is deliberately KU-flavoured: engineering, energy, materials,
22
+ petroleum, AI/CS and biomedical are weighted because that is KU's output
23
+ profile.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any
29
+ import json
30
+ import re
31
+
32
+ # ── Registry ───────────────────────────────────────────────────────────────
33
+ # NOTE: max_file_size / certification are left blank where not personally
34
+ # verified. Fill them in from source_url and bump `verified`.
35
+
36
+ REGISTRY: dict[str, dict[str, Any]] = {
37
+ # ---- Institutional ----------------------------------------------------
38
+ "khazna": {
39
+ "id": "khazna",
40
+ "name": "Khazna",
41
+ "type": "Institutional",
42
+ "url": "https://khazna.ku.ac.ae",
43
+ "scope": "Khalifa University research outputs, datasets and theses across all disciplines.",
44
+ "data_types_accepted": "Confirm file-deposit support with KU Library",
45
+ "persistent_identifier": "",
46
+ "versioning": "",
47
+ "embargo_support": "",
48
+ "cost": "Free for KU researchers",
49
+ "curation": "Mediated — library review before publication",
50
+ "source_url": "https://khazna.ku.ac.ae",
51
+ "verified": "2026-07",
52
+ "is_khazna": True,
53
+ "disciplines": ["all"],
54
+ },
55
+
56
+ # ---- Generalist -------------------------------------------------------
57
+ "zenodo": {
58
+ "id": "zenodo", "name": "Zenodo", "type": "Generalist",
59
+ "url": "https://zenodo.org",
60
+ "scope": "Multidisciplinary repository operated by CERN; accepts data, software, publications.",
61
+ "data_types_accepted": "All types", "persistent_identifier": "DOI",
62
+ "versioning": "Yes", "embargo_support": "Yes", "cost": "Free",
63
+ "curation": "Unmediated",
64
+ "source_url": "https://help.zenodo.org", "verified": "2026-07",
65
+ "disciplines": ["all"],
66
+ },
67
+ "figshare": {
68
+ "id": "figshare", "name": "Figshare", "type": "Generalist",
69
+ "url": "https://figshare.com",
70
+ "scope": "Multidisciplinary repository for figures, datasets, media and preprints.",
71
+ "data_types_accepted": "All types", "persistent_identifier": "DOI",
72
+ "versioning": "Yes", "embargo_support": "Yes", "cost": "Free tier available",
73
+ "curation": "Unmediated",
74
+ "source_url": "https://help.figshare.com", "verified": "2026-07",
75
+ "disciplines": ["all"],
76
+ },
77
+ "dryad": {
78
+ "id": "dryad", "name": "Dryad", "type": "Generalist",
79
+ "url": "https://datadryad.org",
80
+ "scope": "Curated repository for data underlying published research, strong in life and environmental sciences.",
81
+ "data_types_accepted": "Research data (not software)", "persistent_identifier": "DOI",
82
+ "versioning": "Yes", "embargo_support": "Yes",
83
+ "cost": "Fee-based; free where the institution is a member",
84
+ "curation": "Mediated — curator review",
85
+ "source_url": "https://datadryad.org/stash/faq", "verified": "2026-07",
86
+ "disciplines": ["life sciences", "environmental", "ecology", "biology"],
87
+ },
88
+ "dataverse": {
89
+ "id": "dataverse", "name": "Harvard Dataverse", "type": "Generalist",
90
+ "url": "https://dataverse.harvard.edu",
91
+ "scope": "Multidisciplinary repository with strong social science and statistical tooling.",
92
+ "data_types_accepted": "All types", "persistent_identifier": "DOI",
93
+ "versioning": "Yes", "embargo_support": "Yes", "cost": "Free",
94
+ "curation": "Unmediated",
95
+ "source_url": "https://support.dataverse.harvard.edu", "verified": "2026-07",
96
+ "disciplines": ["social sciences", "economics", "political science", "all"],
97
+ },
98
+ "osf": {
99
+ "id": "osf", "name": "Open Science Framework (OSF)", "type": "Generalist",
100
+ "url": "https://osf.io",
101
+ "scope": "Project-based platform combining data, preprints, preregistration and materials.",
102
+ "data_types_accepted": "All types", "persistent_identifier": "DOI",
103
+ "versioning": "Yes", "embargo_support": "Yes", "cost": "Free",
104
+ "curation": "Unmediated",
105
+ "source_url": "https://help.osf.io", "verified": "2026-07",
106
+ "disciplines": ["psychology", "social sciences", "all"],
107
+ },
108
+ "mendeley_data": {
109
+ "id": "mendeley_data", "name": "Mendeley Data", "type": "Generalist",
110
+ "url": "https://data.mendeley.com",
111
+ "scope": "Multidisciplinary data repository operated by Elsevier.",
112
+ "data_types_accepted": "All types", "persistent_identifier": "DOI",
113
+ "versioning": "Yes", "embargo_support": "Yes", "cost": "Free tier available",
114
+ "curation": "Unmediated",
115
+ "source_url": "https://data.mendeley.com", "verified": "2026-07",
116
+ "disciplines": ["all"],
117
+ },
118
+
119
+ # ---- Engineering / energy / materials — KU core ------------------------
120
+ "materials_cloud": {
121
+ "id": "materials_cloud", "name": "Materials Cloud", "type": "Domain-specific",
122
+ "url": "https://www.materialscloud.org",
123
+ "scope": "Computational materials science data, workflows and simulations.",
124
+ "data_types_accepted": "Simulation data, structures, workflows",
125
+ "persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
126
+ "cost": "Free", "curation": "Mediated",
127
+ "source_url": "https://www.materialscloud.org/home", "verified": "2026-07",
128
+ "disciplines": ["materials", "chemistry", "physics", "computational"],
129
+ },
130
+ "nomad": {
131
+ "id": "nomad", "name": "NOMAD", "type": "Domain-specific",
132
+ "url": "https://nomad-lab.eu",
133
+ "scope": "Repository for computational materials science, especially DFT calculations.",
134
+ "data_types_accepted": "Electronic-structure calculation outputs",
135
+ "persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
136
+ "cost": "Free", "curation": "Unmediated",
137
+ "source_url": "https://nomad-lab.eu", "verified": "2026-07",
138
+ "disciplines": ["materials", "physics", "chemistry", "computational"],
139
+ },
140
+ "oedi": {
141
+ "id": "oedi", "name": "Open Energy Data Initiative (OEDI)", "type": "Domain-specific",
142
+ "url": "https://data.openei.org",
143
+ "scope": "Energy systems, renewables, grid and buildings data (US DOE).",
144
+ "data_types_accepted": "Energy datasets, time series, geospatial",
145
+ "persistent_identifier": "DOI", "versioning": "", "embargo_support": "",
146
+ "cost": "Free", "curation": "Mediated",
147
+ "source_url": "https://data.openei.org", "verified": "2026-07",
148
+ "disciplines": ["energy", "renewables", "engineering", "sustainability"],
149
+ },
150
+ "ieee_dataport": {
151
+ "id": "ieee_dataport", "name": "IEEE DataPort", "type": "Domain-specific",
152
+ "url": "https://ieee-dataport.org",
153
+ "scope": "Datasets across electrical engineering, computing, communications and signal processing.",
154
+ "data_types_accepted": "All types", "persistent_identifier": "DOI",
155
+ "versioning": "Yes", "embargo_support": "Yes",
156
+ "cost": "Free for open datasets; subscription for some features",
157
+ "curation": "Unmediated",
158
+ "source_url": "https://ieee-dataport.org", "verified": "2026-07",
159
+ "disciplines": ["engineering", "electrical", "computing", "communications", "signal processing"],
160
+ },
161
+
162
+ # ---- Earth / environment / geospatial ---------------------------------
163
+ "pangaea": {
164
+ "id": "pangaea", "name": "PANGAEA", "type": "Domain-specific",
165
+ "url": "https://www.pangaea.de",
166
+ "scope": "Earth and environmental science data; georeferenced observations.",
167
+ "data_types_accepted": "Georeferenced environmental data",
168
+ "persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
169
+ "cost": "Free", "curation": "Mediated — editorial review",
170
+ "source_url": "https://www.pangaea.de/submit/", "verified": "2026-07",
171
+ "disciplines": ["earth science", "environmental", "climate", "oceanography", "geoscience"],
172
+ },
173
+ "esa_earth": {
174
+ "id": "esa_earth", "name": "ESA Earth Online", "type": "Domain-specific",
175
+ "url": "https://earth.esa.int",
176
+ "scope": "Earth observation and satellite remote-sensing datasets.",
177
+ "data_types_accepted": "Satellite imagery, EO products",
178
+ "persistent_identifier": "", "versioning": "", "embargo_support": "",
179
+ "cost": "Free", "curation": "Mediated",
180
+ "source_url": "https://earth.esa.int", "verified": "2026-07",
181
+ "disciplines": ["remote sensing", "earth observation", "geospatial", "climate"],
182
+ },
183
+
184
+ # ---- Life sciences / biomedical ---------------------------------------
185
+ "ena": {
186
+ "id": "ena", "name": "European Nucleotide Archive (ENA)", "type": "Domain-specific",
187
+ "url": "https://www.ebi.ac.uk/ena",
188
+ "scope": "Nucleotide sequencing data — raw reads, assemblies, annotations.",
189
+ "data_types_accepted": "Sequence data (FASTQ, BAM, assemblies)",
190
+ "persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
191
+ "cost": "Free", "curation": "Mediated — format validation",
192
+ "source_url": "https://www.ebi.ac.uk/ena/browser/submit", "verified": "2026-07",
193
+ "disciplines": ["genomics", "biology", "life sciences", "sequencing", "microbiology"],
194
+ },
195
+ "geo": {
196
+ "id": "geo", "name": "NCBI GEO", "type": "Domain-specific",
197
+ "url": "https://www.ncbi.nlm.nih.gov/geo",
198
+ "scope": "Functional genomics data — gene expression, microarray, high-throughput sequencing.",
199
+ "data_types_accepted": "Expression and functional genomics data",
200
+ "persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
201
+ "cost": "Free", "curation": "Mediated",
202
+ "source_url": "https://www.ncbi.nlm.nih.gov/geo/info/submission.html", "verified": "2026-07",
203
+ "disciplines": ["genomics", "transcriptomics", "biology", "life sciences"],
204
+ },
205
+ "pride": {
206
+ "id": "pride", "name": "PRIDE", "type": "Domain-specific",
207
+ "url": "https://www.ebi.ac.uk/pride",
208
+ "scope": "Mass-spectrometry proteomics data.",
209
+ "data_types_accepted": "Proteomics MS data",
210
+ "persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
211
+ "cost": "Free", "curation": "Mediated",
212
+ "source_url": "https://www.ebi.ac.uk/pride/markdownpage/submitdatapage", "verified": "2026-07",
213
+ "disciplines": ["proteomics", "biochemistry", "life sciences"],
214
+ },
215
+ "pdb": {
216
+ "id": "pdb", "name": "Protein Data Bank (PDB)", "type": "Domain-specific",
217
+ "url": "https://www.rcsb.org",
218
+ "scope": "Three-dimensional structural data for biological macromolecules.",
219
+ "data_types_accepted": "Macromolecular structures",
220
+ "persistent_identifier": "PDB ID", "versioning": "Yes", "embargo_support": "Yes",
221
+ "cost": "Free", "curation": "Mediated — biocuration",
222
+ "source_url": "https://www.rcsb.org/pages/policy", "verified": "2026-07",
223
+ "disciplines": ["structural biology", "biochemistry", "chemistry", "life sciences"],
224
+ },
225
+ "metabolights": {
226
+ "id": "metabolights", "name": "MetaboLights", "type": "Domain-specific",
227
+ "url": "https://www.ebi.ac.uk/metabolights",
228
+ "scope": "Metabolomics experiments and derived information.",
229
+ "data_types_accepted": "Metabolomics data",
230
+ "persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
231
+ "cost": "Free", "curation": "Mediated",
232
+ "source_url": "https://www.ebi.ac.uk/metabolights", "verified": "2026-07",
233
+ "disciplines": ["metabolomics", "biochemistry", "life sciences"],
234
+ },
235
+
236
+ # ---- Chemistry --------------------------------------------------------
237
+ "chemrxiv_data": {
238
+ "id": "chemrxiv_data", "name": "Cambridge Structural Database (CCDC)",
239
+ "type": "Domain-specific", "url": "https://www.ccdc.cam.ac.uk",
240
+ "scope": "Small-molecule organic and metal-organic crystal structures.",
241
+ "data_types_accepted": "Crystallographic data (CIF)",
242
+ "persistent_identifier": "CCDC number", "versioning": "", "embargo_support": "Yes",
243
+ "cost": "Free deposition", "curation": "Mediated",
244
+ "source_url": "https://www.ccdc.cam.ac.uk/structures/", "verified": "2026-07",
245
+ "disciplines": ["chemistry", "crystallography", "materials"],
246
+ },
247
+
248
+ # ---- Computing / AI ---------------------------------------------------
249
+ "huggingface_datasets": {
250
+ "id": "huggingface_datasets", "name": "Hugging Face Datasets",
251
+ "type": "Domain-specific", "url": "https://huggingface.co/datasets",
252
+ "scope": "Machine learning datasets and models with versioned git-based storage.",
253
+ "data_types_accepted": "ML datasets, models",
254
+ "persistent_identifier": "", "versioning": "Yes", "embargo_support": "",
255
+ "cost": "Free tier available", "curation": "Unmediated",
256
+ "source_url": "https://huggingface.co/docs/hub/datasets", "verified": "2026-07",
257
+ "disciplines": ["machine learning", "ai", "nlp", "computer vision", "computing"],
258
+ },
259
+ "openml": {
260
+ "id": "openml", "name": "OpenML", "type": "Domain-specific",
261
+ "url": "https://www.openml.org",
262
+ "scope": "Machine learning datasets, tasks, flows and benchmark runs.",
263
+ "data_types_accepted": "Tabular ML datasets",
264
+ "persistent_identifier": "", "versioning": "Yes", "embargo_support": "",
265
+ "cost": "Free", "curation": "Unmediated",
266
+ "source_url": "https://docs.openml.org", "verified": "2026-07",
267
+ "disciplines": ["machine learning", "ai", "data science", "computing"],
268
+ },
269
+
270
+ # ---- Social science / humanities --------------------------------------
271
+ "ukds": {
272
+ "id": "ukds", "name": "UK Data Service ReShare", "type": "Domain-specific",
273
+ "url": "https://reshare.ukdataservice.ac.uk",
274
+ "scope": "Social and economic research data, including sensitive data under controlled access.",
275
+ "data_types_accepted": "Social science data",
276
+ "persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
277
+ "cost": "Free", "curation": "Mediated",
278
+ "source_url": "https://reshare.ukdataservice.ac.uk", "verified": "2026-07",
279
+ "disciplines": ["social sciences", "economics", "sociology", "survey"],
280
+ },
281
+ "icpsr": {
282
+ "id": "icpsr", "name": "ICPSR", "type": "Domain-specific",
283
+ "url": "https://www.icpsr.umich.edu",
284
+ "scope": "Social and behavioural science data with strong curation and restricted-access options.",
285
+ "data_types_accepted": "Social science data",
286
+ "persistent_identifier": "DOI", "versioning": "Yes", "embargo_support": "Yes",
287
+ "cost": "Varies by membership", "curation": "Mediated — extensive curation",
288
+ "source_url": "https://www.icpsr.umich.edu/web/pages/deposit/", "verified": "2026-07",
289
+ "disciplines": ["social sciences", "behavioural", "health", "survey"],
290
+ },
291
+
292
+ # ---- Sensitive / controlled access -------------------------------------
293
+ "ega": {
294
+ "id": "ega", "name": "European Genome-phenome Archive (EGA)",
295
+ "type": "Domain-specific", "url": "https://ega-archive.org",
296
+ "scope": "Controlled-access repository for personally identifiable genetic and phenotypic data.",
297
+ "data_types_accepted": "Human genomic and phenotypic data",
298
+ "persistent_identifier": "Accession", "versioning": "Yes", "embargo_support": "Yes",
299
+ "cost": "Free", "curation": "Mediated — DAC-controlled access",
300
+ "source_url": "https://ega-archive.org/submission", "verified": "2026-07",
301
+ "disciplines": ["human genomics", "clinical", "biomedical", "sensitive"],
302
+ },
303
+ }
304
+
305
+
306
+ # ── Helpers ────────────────────────────────────────────────────────────────
307
+ # The model may author ONLY analytical fit fields. Every other repository
308
+ # attribute is supplied from REGISTRY, including the id itself after lookup.
309
+ MODEL_ALLOWED = ("fit_reason", "fit_confidence")
310
+
311
+
312
+ def _clean_text(value: Any, limit: int = 1000) -> str:
313
+ return " ".join(str(value or "").split())[:limit]
314
+
315
+
316
+ def _clean_list(value: Any, limit: int = 12, item_limit: int = 500) -> list[str]:
317
+ if not isinstance(value, list):
318
+ return []
319
+ out: list[str] = []
320
+ for item in value[:limit]:
321
+ cleaned = _clean_text(item, item_limit)
322
+ if cleaned:
323
+ out.append(cleaned)
324
+ return out
325
+
326
+
327
+ def _yes_no(value: Any) -> str:
328
+ text = _clean_text(value, 100).casefold()
329
+ if text in {"yes", "true"} or text.startswith("yes ") or text.startswith("yes—"):
330
+ return "Yes"
331
+ if text in {"no", "false"}:
332
+ return "No"
333
+ return "Unknown"
334
+
335
+
336
+ def repository_capabilities(entry: dict) -> dict:
337
+ """Expose only capabilities supported by existing curated fields.
338
+
339
+ A generic homepage and a broad access description do not establish approval
340
+ for a particular sensitive dataset, size limit or accepted file format.
341
+ """
342
+ identifier = _clean_text(entry.get("persistent_identifier"), 100)
343
+ description = (str(entry.get("scope", "")) + " " + str(entry.get("curation", ""))).casefold()
344
+ controlled = any(term in description for term in ("controlled access", "controlled-access", "restricted-access", "restricted access"))
345
+ return {
346
+ "doi": "Yes" if identifier.casefold() == "doi" else "No" if identifier else "Unknown",
347
+ "persistent_id_type": identifier or "Unknown",
348
+ "versioning": _yes_no(entry.get("versioning")),
349
+ "embargo": _yes_no(entry.get("embargo_support")),
350
+ "restricted_access": "Yes" if controlled and not entry.get("is_khazna") else "Unknown",
351
+ "approved_sensitive_data": "Unknown",
352
+ "accepts_external_deposits": "Unknown",
353
+ "accepted_file_formats": "Unknown",
354
+ "maximum_dataset_size": "Unknown",
355
+ "source_url": entry.get("source_url", ""),
356
+ "reviewed": entry.get("verified", ""),
357
+ }
358
+
359
+
360
+ def evaluate_repository(entry: dict, constraints: dict | None = None) -> dict:
361
+ """Check stated requirements against curated facts; unknown is not Yes."""
362
+ constraints = constraints if isinstance(constraints, dict) else {}
363
+ capabilities = repository_capabilities(entry)
364
+ unresolved, conflicts = [], []
365
+ sensitivity = _clean_text(constraints.get("sensitivity"), 100).casefold()
366
+ personal = _clean_text(constraints.get("contains_personal_data"), 100).casefold()
367
+ sensitive = (bool(sensitivity) and sensitivity not in {"open", "none", "non-sensitive", "not sensitive"}) or (bool(personal) and personal != "no")
368
+ for requirement, capability, label in (
369
+ ("needs_doi", "doi", "DOI assignment"),
370
+ ("needs_versioning", "versioning", "versioning"),
371
+ ("embargo_required", "embargo", "an embargo"),
372
+ ):
373
+ if _yes_no(constraints.get(requirement)) != "Yes":
374
+ continue
375
+ if capabilities[capability] == "No":
376
+ conflicts.append(f"The curated record does not support the requested {label}.")
377
+ elif capabilities[capability] != "Yes":
378
+ unresolved.append(f"Confirm support for {label} with the repository.")
379
+ if sensitive:
380
+ if capabilities["restricted_access"] != "Yes":
381
+ conflicts.append("Controlled/restricted access suitable for the submitted sensitive or personal data is not established.")
382
+ unresolved.append("KU Library and the responsible ethics/data-protection team must approve the repository and access conditions for this dataset before any file upload.")
383
+ if _clean_text(constraints.get("file_formats")):
384
+ unresolved.append("Confirm acceptance of the submitted file formats: " + _clean_text(constraints["file_formats"], 300))
385
+ if _clean_text(constraints.get("approx_size")):
386
+ unresolved.append("Confirm the size/quota and any associated cost for: " + _clean_text(constraints["approx_size"], 300))
387
+ if _clean_text(constraints.get("funder")):
388
+ unresolved.append("Confirm the funder's repository, licence and retention requirements; the registry does not establish mandate compliance.")
389
+ if _clean_text(constraints.get("licence_intent")):
390
+ unresolved.append("Confirm that the repository supports the selected licence and that you hold the necessary rights.")
391
+ institution = _clean_text(constraints.get("institution"))
392
+ country = _clean_text(constraints.get("country"))
393
+ if institution or country:
394
+ unresolved.append("Confirm depositor eligibility and any institution/country restrictions.")
395
+ if _yes_no(constraints.get("embargo_required")) == "Yes":
396
+ duration = constraints.get("embargo_months")
397
+ if duration is not None:
398
+ unresolved.append(f"Confirm support for the requested {duration}-month embargo.")
399
+ elif constraints.get("embargo_duration"):
400
+ unresolved.append("Confirm support for the requested embargo duration: " + _clean_text(constraints["embargo_duration"], 100))
401
+ if entry.get("id") == "dryad" and "software" in _clean_text(constraints.get("data_types")).casefold():
402
+ conflicts.append("The curated record lists research data, not software, as accepted content.")
403
+ metadata_only = bool(entry.get("is_khazna"))
404
+ if metadata_only:
405
+ unresolved.insert(0, "Ask KU Library about metadata registration; dataset file acceptance, DOI assignment and access controls require confirmation.")
406
+ # Institutional registration advice is not a file-deposit destination.
407
+ conflicts = []
408
+ return {
409
+ "state": "metadata_only" if metadata_only else "does_not_meet" if conflicts else "conditional" if unresolved else "meets",
410
+ "unresolved_constraints": list(dict.fromkeys(unresolved)),
411
+ "constraint_conflicts": list(dict.fromkeys(conflicts)),
412
+ "sensitive_data_review_required": sensitive,
413
+ "file_deposit_recommended": not metadata_only and not conflicts and not unresolved,
414
+ "strategy_eligible": not metadata_only and not conflicts and not sensitive,
415
+ "capabilities": capabilities,
416
+ }
417
+
418
+
419
+ def registry_for_prompt(limit: int = 0) -> str:
420
+ """Compact registry listing for the model to select from."""
421
+ lines = []
422
+ for r in REGISTRY.values():
423
+ lines.append(
424
+ f'- id="{r["id"]}" | {r["name"]} | {r["type"]} | '
425
+ f'disciplines: {", ".join(r["disciplines"])} | {r["scope"]} | '
426
+ f'capabilities: {json.dumps(repository_capabilities(r), ensure_ascii=False)} | '
427
+ f'accepted content: {r.get("data_types_accepted", "Unknown")} | cost: {r.get("cost", "Unknown")}'
428
+ )
429
+ if limit and len(lines) >= limit:
430
+ break
431
+ return "\n".join(lines)
432
+
433
+
434
+ def hydrate_selection(selected: list[dict], constraints: dict | None = None) -> list[dict]:
435
+ """
436
+ Hydrate exact registry ids and discard every model-authored factual field.
437
+
438
+ Only `fit_reason` and `fit_confidence` survive from the model. Unknown ids,
439
+ duplicate ids, non-object entries, and malformed confidence values are
440
+ dropped or normalised rather than displayed as repository facts.
441
+ """
442
+ out: list[dict] = []
443
+ seen: set[str] = set()
444
+ if not isinstance(selected, list):
445
+ return out
446
+
447
+ for item in selected:
448
+ if not isinstance(item, dict):
449
+ continue
450
+ rid = str(item.get("id", "")).strip().lower()
451
+ entry = REGISTRY.get(rid)
452
+ if not entry or rid in seen:
453
+ continue
454
+ seen.add(rid)
455
+ assessment = evaluate_repository(entry, constraints)
456
+ if constraints is not None and assessment["state"] == "does_not_meet":
457
+ continue
458
+
459
+ fit_reason = _clean_text(item.get("fit_reason"), 1200)
460
+ confidence = _clean_text(item.get("fit_confidence"), 20).title()
461
+ if confidence not in {"High", "Medium", "Low"}:
462
+ confidence = ""
463
+
464
+ merged = dict(entry)
465
+ merged["fit_reason"] = fit_reason
466
+ merged["fit_confidence"] = confidence
467
+ # Backwards-compatible alias for older frontends; it is still an
468
+ # analytical fit score, never a repository-fact confidence score.
469
+ merged["confidence"] = confidence
470
+ merged["verification_status"] = "Curated KU Library registry record"
471
+ merged["verified_note"] = (
472
+ f"Repository details verified {entry['verified']} from {entry['source_url']}"
473
+ )
474
+ merged["capabilities"] = assessment["capabilities"]
475
+ merged["constraint_assessment"] = assessment
476
+ merged["file_deposit_recommended"] = assessment["file_deposit_recommended"]
477
+ merged["metadata_registration_only"] = bool(entry.get("is_khazna"))
478
+ if constraints is not None and assessment["state"] == "conditional":
479
+ merged["fit_reason"] = ("Candidate for review. " + fit_reason + " " + " ".join(assessment["unresolved_constraints"]))[:3000]
480
+ merged["fit_confidence"] = "Low" if assessment["sensitive_data_review_required"] else "Medium"
481
+ merged["confidence"] = merged["fit_confidence"]
482
+ out.append(merged)
483
+ return out
484
+
485
+
486
+ def selection_exclusions(selected: list[dict], constraints: dict | None = None) -> list[dict]:
487
+ """Explain deterministic exclusions without trusting model-authored facts."""
488
+ excluded, seen = [], set()
489
+ for item in selected if isinstance(selected, list) else []:
490
+ if not isinstance(item, dict):
491
+ continue
492
+ rid = _clean_text(item.get("id"), 100).lower()
493
+ entry = REGISTRY.get(rid)
494
+ if not entry or rid in seen:
495
+ continue
496
+ seen.add(rid)
497
+ result = evaluate_repository(entry, constraints)
498
+ if result["state"] == "does_not_meet":
499
+ excluded.append({"id": rid, "name": entry["name"], "reasons": result["constraint_conflicts"],
500
+ "unresolved_constraints": result["unresolved_constraints"]})
501
+ return excluded
502
+
503
+
504
+ def hydrate_deposit_strategy(raw: Any, hydrated: list[dict], constraints: dict | None = None) -> dict:
505
+ """Resolve model-selected ids to server-owned names and bound free text."""
506
+ raw = raw if isinstance(raw, dict) else {}
507
+ by_id = {str(r.get("id", "")): r for r in hydrated if r.get("id")}
508
+ eligible = {rid for rid, entry in by_id.items()
509
+ if not entry.get("is_khazna") and entry.get("constraint_assessment", {}).get("strategy_eligible", True)}
510
+ ranked_ids = [
511
+ str(r.get("id")) for r in hydrated
512
+ if r.get("id") in eligible
513
+ ]
514
+
515
+ def selected_id(key: str, fallback_index: int) -> str:
516
+ candidate = str(raw.get(key, "")).strip().lower()
517
+ if candidate in eligible:
518
+ return candidate
519
+ return ranked_ids[fallback_index] if len(ranked_ids) > fallback_index else ""
520
+
521
+ primary_id = selected_id("primary_repository_id", 0)
522
+ secondary_id = selected_id("secondary_repository_id", 1)
523
+ if secondary_id == primary_id:
524
+ secondary_id = ranked_ids[1] if len(ranked_ids) > 1 else ""
525
+
526
+ checks = _clean_list(raw.get("manual_checks_required"), limit=10)
527
+ for entry in hydrated:
528
+ checks.extend(entry.get("constraint_assessment", {}).get("unresolved_constraints", []))
529
+ if not primary_id:
530
+ checks.insert(0, "No file-deposit destination has been established for these requirements; contact KU Library before uploading data.")
531
+ workflow = _clean_list(raw.get("deposit_workflow"), limit=10)
532
+ if constraints is not None:
533
+ # Model prose must not instruct immediate upload while deterministic
534
+ # requirements remain unresolved, especially for sensitive datasets.
535
+ workflow = (["Review the candidate repository's source guidance and all unresolved requirements.",
536
+ "Confirm rights, consent, access conditions and depositor eligibility.",
537
+ "Prepare metadata and supporting documentation; upload files only after all checks are complete."]
538
+ if primary_id else ["Contact KU Library to identify an approved deposit or metadata-registration route.",
539
+ "Do not upload dataset files until a suitable destination and access conditions are confirmed."])
540
+ return {
541
+ "primary_repository_id": primary_id,
542
+ "primary_url": REGISTRY.get(primary_id, {}).get("url"),
543
+ "secondary_url": REGISTRY.get(secondary_id, {}).get("url"),
544
+ "secondary_repository_id": secondary_id,
545
+ "primary_choice": by_id.get(primary_id, {}).get("name", "Not determined"),
546
+ "secondary_choice": by_id.get(secondary_id, {}).get("name", "Not determined"),
547
+ "metadata_standard": _clean_text(
548
+ raw.get("metadata_standard") or "Check the selected repository's current metadata guidance.",
549
+ 500,
550
+ ),
551
+ "deposit_workflow": workflow,
552
+ "manual_checks_required": list(dict.fromkeys(checks)),
553
+ "selection_status": "candidates_require_review" if checks and primary_id else "no_verified_destination" if not primary_id else "requirements_matched",
554
+ "khazna_metadata_note": (
555
+ "Ask KU Library about registering dataset metadata in Khazna when the files are held in an appropriate external repository."
556
+ ),
557
+ }
558
+
559
+
560
+ def khazna_entry(constraints: dict | None = None) -> dict:
561
+ e = dict(REGISTRY["khazna"])
562
+ e["fit_reason"] = ("Contact KU Library about registering dataset metadata in Khazna for institutional visibility. "
563
+ "File-deposit eligibility, DOI assignment and access controls must be confirmed separately.")
564
+ e["metadata_registration_only"] = True
565
+ e["file_deposit_recommended"] = False
566
+ e["constraint_assessment"] = evaluate_repository(e, constraints)
567
+ e["capabilities"] = e["constraint_assessment"]["capabilities"]
568
+ e["fit_confidence"] = "High"
569
+ e["confidence"] = "High"
570
+ e["verification_status"] = "Curated KU Library registry record"
571
+ e["verified_note"] = (f"Repository details verified {e['verified']} "
572
+ f"from {e['source_url']}")
573
+ return e
574
+
575
+
576
+ # ══════════════════════════════════════════════════════════════════════════
577
+ # SUBJECT REPOSITORIES — for ARTICLES, not datasets
578
+ # ══════════════════════════════════════════════════════════════════════════
579
+ # OA.Works returns permitted locations such as "Non-commercial Subject
580
+ # Repository". That string alone is not actionable: the author still has to
581
+ # know WHICH one. These entries are matched against the journal's SCImago
582
+ # subject areas/categories so the answer is specific.
583
+ #
584
+ # Deliberately distinct from REGISTRY above, which holds DATA repositories.
585
+ # Depositing a manuscript in Dryad or a dataset in arXiv are both wrong.
586
+
587
+ SUBJECT_REPOSITORIES: dict[str, dict] = {
588
+ "arxiv": {
589
+ "id": "arxiv", "name": "arXiv", "url": "https://arxiv.org",
590
+ "scope": "Physics, mathematics, computer science, quantitative biology, "
591
+ "statistics, electrical engineering and economics.",
592
+ "versions": "Preprint and accepted manuscript",
593
+ "moderation": "Moderated; endorsement required for first submission",
594
+ "source_url": "https://arxiv.org/help/submit", "verified": "2026-07",
595
+ "match": ["physics", "astronomy", "mathematics", "computer science",
596
+ "engineering", "electrical", "statistics", "economics",
597
+ "materials science", "energy"],
598
+ },
599
+ "pmc": {
600
+ "id": "pmc", "name": "Europe PMC / PubMed Central",
601
+ "url": "https://europepmc.org",
602
+ "scope": "Biomedical and life sciences literature. Required by many "
603
+ "health funders.",
604
+ "versions": "Accepted manuscript and published version where licensed",
605
+ "moderation": "Mediated; funder-linked deposit routes available",
606
+ "source_url": "https://europepmc.org/Help", "verified": "2026-07",
607
+ "match": ["medicine", "biochemistry", "immunology", "microbiology",
608
+ "neuroscience", "pharmacology", "health", "nursing",
609
+ "veterinary", "dentistry", "biology"],
610
+ },
611
+ "biorxiv": {
612
+ "id": "biorxiv", "name": "bioRxiv", "url": "https://www.biorxiv.org",
613
+ "scope": "Preprints across the biological sciences.",
614
+ "versions": "Preprint (submitted version) only",
615
+ "moderation": "Screened, not peer reviewed",
616
+ "source_url": "https://www.biorxiv.org/about/FAQ", "verified": "2026-07",
617
+ "match": ["biology", "biochemistry", "genetics", "neuroscience",
618
+ "immunology", "microbiology", "agricultural"],
619
+ },
620
+ "chemrxiv": {
621
+ "id": "chemrxiv", "name": "ChemRxiv", "url": "https://chemrxiv.org",
622
+ "scope": "Preprints in chemistry and related areas.",
623
+ "versions": "Preprint (submitted version) only",
624
+ "moderation": "Screened, not peer reviewed",
625
+ "source_url": "https://chemrxiv.org/engage/chemrxiv/public-dashboard",
626
+ "verified": "2026-07",
627
+ "match": ["chemistry", "chemical engineering", "materials science"],
628
+ },
629
+ "engrxiv": {
630
+ "id": "engrxiv", "name": "engrXiv", "url": "https://engrxiv.org",
631
+ "scope": "Preprints across all engineering disciplines.",
632
+ "versions": "Preprint and accepted manuscript",
633
+ "moderation": "Screened, not peer reviewed",
634
+ "source_url": "https://engrxiv.org", "verified": "2026-07",
635
+ "match": ["engineering", "civil", "mechanical", "aerospace",
636
+ "industrial", "environmental engineering"],
637
+ },
638
+ "ssrn": {
639
+ "id": "ssrn", "name": "SSRN", "url": "https://www.ssrn.com",
640
+ "scope": "Social sciences, economics, management, law and humanities.",
641
+ "versions": "Preprint and accepted manuscript",
642
+ "moderation": "Screened",
643
+ "source_url": "https://www.ssrn.com/index.cfm/en/", "verified": "2026-07",
644
+ "match": ["social sciences", "economics", "business", "management",
645
+ "accounting", "finance", "law", "psychology", "decision sciences"],
646
+ },
647
+ "repec": {
648
+ "id": "repec", "name": "RePEc", "url": "http://repec.org",
649
+ "scope": "Working papers and articles in economics and finance.",
650
+ "versions": "Working paper and accepted manuscript",
651
+ "moderation": "Institution-mediated archives",
652
+ "source_url": "http://repec.org/", "verified": "2026-07",
653
+ "match": ["economics", "econometrics", "finance"],
654
+ },
655
+ "eartharxiv": {
656
+ "id": "eartharxiv", "name": "EarthArXiv",
657
+ "url": "https://eartharxiv.org",
658
+ "scope": "Preprints in earth, planetary and environmental sciences.",
659
+ "versions": "Preprint and accepted manuscript",
660
+ "moderation": "Screened, not peer reviewed",
661
+ "source_url": "https://eartharxiv.org", "verified": "2026-07",
662
+ "match": ["earth", "planetary", "environmental science", "geology",
663
+ "atmospheric", "oceanography"],
664
+ },
665
+ "medrxiv": {
666
+ "id": "medrxiv", "name": "medRxiv", "url": "https://www.medrxiv.org",
667
+ "scope": "Preprints in clinical and health sciences.",
668
+ "versions": "Preprint (submitted version) only",
669
+ "moderation": "Screened; clinical claims reviewed",
670
+ "source_url": "https://www.medrxiv.org/about/FAQ", "verified": "2026-07",
671
+ "match": ["medicine", "health", "clinical", "epidemiology", "public health"],
672
+ },
673
+ }
674
+
675
+ # Always offered when a subject repository is permitted but nothing matched.
676
+ SUBJECT_FALLBACK = ["arxiv", "ssrn"]
677
+
678
+
679
+ def suggest_subject_repositories(areas: str = "", categories: str = "",
680
+ limit: int = 3) -> list[dict]:
681
+ """
682
+ Match a journal's SCImago subject areas/categories to subject repositories.
683
+
684
+ Deterministic keyword scoring — no LLM. Returns [] rather than guessing
685
+ when nothing matches well and no fallback is warranted.
686
+ """
687
+ haystack = f"{areas} {categories}".lower()
688
+ if not haystack.strip():
689
+ return []
690
+
691
+ scored: list[tuple[int, dict]] = []
692
+ for entry in SUBJECT_REPOSITORIES.values():
693
+ score = sum(1 for kw in entry["match"] if kw in haystack)
694
+ if score:
695
+ scored.append((score, entry))
696
+
697
+ scored.sort(key=lambda x: (-x[0], x[1]["name"]))
698
+ picked = [dict(e) for _, e in scored[:limit]]
699
+
700
+ # An unrelated fallback is not a subject match. Return no suggestions if
701
+ # the supplied discipline does not match a curated repository.
702
+ if not picked:
703
+ return []
704
+
705
+ for p in picked:
706
+ p.pop("match", None)
707
+ p["verified_note"] = f"Verified {p['verified']} from {p['source_url']}"
708
+ return picked
scimago_index.py CHANGED
@@ -183,6 +183,330 @@ def closest_subject_term(text: str, cutoff: float = 0.90) -> Optional[Dict[str,
183
  }
184
 
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  _subject_token_df_cache: Optional[Dict[str, float]] = None
187
 
188
 
 
183
  }
184
 
185
 
186
+ # ── Deterministic nearest-subject resolution ──────────────────────────────
187
+ #
188
+ # Researchers type everyday discipline names ("physics", "maths", "machine
189
+ # learning") that are not literal SCImago/ASJC names. Previously such input
190
+ # depended entirely on the LLM classifier; when it was unavailable or answered
191
+ # "not_found", the user saw no journals at all.
192
+ #
193
+ # This resolver is deliberately conservative and auditable:
194
+ # 1. A reviewed alias table maps common discipline names onto real
195
+ # vocabulary terms. Every target is validated against the live
196
+ # vocabulary at call time, so a renamed category is skipped, never
197
+ # invented (a test also fails if any target disappears).
198
+ # 2. Whole-word containment: every significant query word must appear in
199
+ # the vocabulary name ("physics" -> area "physics and astronomy").
200
+ # 3. (Only after the LLM has failed) a partial match on a distinctive word.
201
+ # Character-level similarity is NOT used here; that is the spelling repair in
202
+ # closest_subject_term, which rejects "criminology" -> "immunology".
203
+
204
+ # kind: "area" or "category". match: "synonym" for true equivalents, otherwise
205
+ # "nearest". Alternatives are optional extra verified categories to offer.
206
+ _SUBJECT_ALIASES: Dict[str, Dict[str, Any]] = {}
207
+
208
+
209
+ def _alias(keys: List[str], kind: str, name: str, match: str = "nearest",
210
+ alternatives: Tuple[str, ...] = ()) -> None:
211
+ for key in keys:
212
+ _SUBJECT_ALIASES[_normalise_subject_match_text(key)] = {
213
+ "kind": kind, "name": name, "match": match,
214
+ "alternatives": list(alternatives),
215
+ }
216
+
217
+
218
+ # Mathematics, statistics, physics
219
+ _alias(["math", "maths", "mathematic"], "area", "mathematics", "synonym",
220
+ ("applied mathematics", "mathematics (miscellaneous)", "statistics and probability"))
221
+ _alias(["stats"], "category", "statistics and probability", "synonym")
222
+ _alias(["quantum physics", "quantum mechanics", "theoretical physics"], "area", "physics and astronomy",
223
+ alternatives=("atomic and molecular physics, and optics", "mathematical physics",
224
+ "statistical and nonlinear physics", "nuclear and high energy physics"))
225
+ _alias(["photonics", "optics and photonics", "lasers"], "category", "atomic and molecular physics, and optics",
226
+ alternatives=("electronic, optical and magnetic materials", "electrical and electronic engineering"))
227
+ _alias(["semiconductors", "semiconductor", "microelectronics"], "category", "electronic, optical and magnetic materials",
228
+ alternatives=("electrical and electronic engineering", "condensed matter physics"))
229
+ # Computing and AI
230
+ _alias(["ai", "a i", "machine learning", "deep learning", "neural networks", "neural network",
231
+ "natural language processing", "nlp", "reinforcement learning", "generative ai",
232
+ "large language models", "llm", "llms"], "category", "artificial intelligence",
233
+ alternatives=("computer vision and pattern recognition", "computer science applications",
234
+ "information systems"))
235
+ _alias(["computer vision", "image processing", "pattern recognition"], "category",
236
+ "computer vision and pattern recognition",
237
+ alternatives=("artificial intelligence", "signal processing", "software"))
238
+ _alias(["cs", "comp sci", "computing", "informatics", "computer engineering"], "area", "computer science",
239
+ alternatives=("computer science applications", "hardware and architecture", "software"))
240
+ _alias(["data science", "big data", "data analytics", "data mining"], "area", "computer science",
241
+ alternatives=("information systems", "artificial intelligence", "statistics and probability"))
242
+ _alias(["software engineering", "programming"], "category", "software",
243
+ alternatives=("computer science applications", "information systems"))
244
+ _alias(["cybersecurity", "cyber security", "computer security", "information security",
245
+ "network security", "cryptography"], "category", "computer networks and communications",
246
+ alternatives=("information systems", "safety, risk, reliability and quality", "hardware and architecture"))
247
+ _alias(["networking", "internet of things", "iot", "telecommunications", "telecommunication",
248
+ "wireless communications", "wireless communication", "communications engineering"],
249
+ "category", "computer networks and communications",
250
+ alternatives=("electrical and electronic engineering", "signal processing", "hardware and architecture"))
251
+ _alias(["hci", "user experience", "ux"], "category", "human-computer interaction",
252
+ alternatives=("human factors and ergonomics", "computer science applications"))
253
+ _alias(["robotics", "robots", "autonomous systems", "mechatronics"], "category", "control and systems engineering",
254
+ alternatives=("artificial intelligence", "mechanical engineering", "electrical and electronic engineering"))
255
+ # Engineering, energy, materials
256
+ _alias(["electronics", "electronic engineering"], "category", "electrical and electronic engineering", "synonym")
257
+ _alias(["power systems", "power engineering", "smart grid", "smart grids", "energy storage",
258
+ "batteries", "battery"], "category", "energy engineering and power technology",
259
+ alternatives=("electrical and electronic engineering", "renewable energy, sustainability and the environment",
260
+ "electrochemistry"))
261
+ _alias(["solar energy", "wind energy", "clean energy", "green energy"], "category",
262
+ "renewable energy, sustainability and the environment",
263
+ alternatives=("energy engineering and power technology", "energy (miscellaneous)"))
264
+ _alias(["hydrogen", "hydrogen energy", "fuel cells", "fuel cell"], "category", "fuel technology",
265
+ alternatives=("energy engineering and power technology", "renewable energy, sustainability and the environment",
266
+ "electrochemistry"))
267
+ _alias(["petroleum", "petroleum engineering", "oil and gas", "reservoir engineering", "petroleum geoscience"],
268
+ "category", "energy engineering and power technology",
269
+ alternatives=("fuel technology", "geotechnical engineering and engineering geology", "geochemistry and petrology"))
270
+ _alias(["materials engineering", "material science", "materials"], "area", "materials science", "synonym",
271
+ alternatives=("materials chemistry", "mechanics of materials", "electronic, optical and magnetic materials"))
272
+ _alias(["nanotech"], "category", "nanoscience and nanotechnology", "synonym")
273
+ _alias(["biotech"], "category", "biotechnology", "synonym")
274
+ _alias(["aeronautics", "aeronautical engineering", "aviation", "astronautics"], "category", "aerospace engineering")
275
+ _alias(["heat transfer", "fluid mechanics", "fluid dynamics", "cfd"], "category", "fluid flow and transfer processes",
276
+ alternatives=("mechanical engineering", "computational mechanics"))
277
+ _alias(["desalination", "water resources", "hydrology", "water treatment"], "category", "water science and technology",
278
+ alternatives=("filtration and separation", "environmental engineering"))
279
+ _alias(["supply chain", "supply chain management", "logistics"], "category", "management science and operations research",
280
+ alternatives=("transportation", "strategy and management", "industrial and manufacturing engineering"))
281
+ _alias(["transport", "transportation engineering", "traffic engineering"], "category", "transportation",
282
+ alternatives=("civil and structural engineering", "urban studies"))
283
+ _alias(["urban planning", "urban design", "city planning", "regional planning"], "category", "urban studies",
284
+ alternatives=("geography, planning and development", "architecture"))
285
+ _alias(["remote sensing", "gis", "geographic information systems", "geoinformatics"], "category",
286
+ "computers in earth sciences",
287
+ alternatives=("earth-surface processes", "geography, planning and development"))
288
+ # Earth and environment
289
+ _alias(["geoscience", "geosciences", "earth science", "earth sciences"], "area", "earth and planetary sciences", "synonym",
290
+ alternatives=("geology", "geophysics", "geochemistry and petrology"))
291
+ _alias(["climate", "climate change", "climate science"], "category", "global and planetary change",
292
+ alternatives=("atmospheric science", "environmental science (miscellaneous)"))
293
+ _alias(["meteorology"], "category", "atmospheric science")
294
+ _alias(["environment", "environmental studies", "environmental sciences"], "area", "environmental science", "synonym",
295
+ alternatives=("environmental engineering", "pollution", "ecology"))
296
+ _alias(["marine science", "marine sciences", "marine biology"], "category", "aquatic science",
297
+ alternatives=("oceanography", "ecology"))
298
+ _alias(["agriculture", "agricultural sciences"], "area", "agricultural and biological sciences", "synonym",
299
+ alternatives=("agronomy and crop science", "soil science", "food science"))
300
+ # Life and health sciences
301
+ _alias(["life sciences", "life science", "biological sciences", "biosciences", "bioscience"], "area",
302
+ "biochemistry, genetics and molecular biology",
303
+ alternatives=("agricultural and biological sciences (miscellaneous)", "molecular biology", "cell biology", "ecology"))
304
+ _alias(["bioinformatics", "computational biology"], "category", "molecular biology",
305
+ alternatives=("computational theory and mathematics", "computer science applications",
306
+ "statistics and probability", "genetics"))
307
+ _alias(["genomics", "genome"], "category", "genetics",
308
+ alternatives=("molecular biology", "biotechnology"))
309
+ _alias(["biochem"], "category", "biochemistry", "synonym")
310
+ _alias(["chem"], "area", "chemistry", "synonym")
311
+ _alias(["neurobiology"], "area", "neuroscience",
312
+ alternatives=("cellular and molecular neuroscience", "cognitive neuroscience"))
313
+ _alias(["biomedical", "biomedical science", "biomedical sciences", "biomedicine", "medical",
314
+ "medical sciences", "clinical", "clinical medicine", "health sciences", "healthcare", "health care"],
315
+ "area", "medicine",
316
+ alternatives=("molecular medicine", "biomedical engineering", "health policy",
317
+ "public health, environmental and occupational health"))
318
+ _alias(["health", "global health", "population health"], "category",
319
+ "public health, environmental and occupational health",
320
+ alternatives=("health policy", "epidemiology", "medicine (miscellaneous)"))
321
+ _alias(["medical imaging"], "category", "radiology, nuclear medicine and imaging")
322
+ _alias(["dental", "dental medicine", "oral health"], "area", "dentistry", "synonym",
323
+ alternatives=("oral surgery", "orthodontics", "periodontics"))
324
+ _alias(["veterinary medicine", "veterinary science"], "area", "veterinary", "synonym")
325
+ _alias(["covid", "covid 19", "covid19", "infectious disease"], "category", "infectious diseases",
326
+ alternatives=("epidemiology", "virology", "public health, environmental and occupational health"))
327
+ _alias(["cognitive science"], "category", "experimental and cognitive psychology",
328
+ alternatives=("cognitive neuroscience", "artificial intelligence"))
329
+ # Social sciences, business, humanities
330
+ _alias(["politics", "political science", "international politics"], "category",
331
+ "political science and international relations", "synonym",
332
+ alternatives=("sociology and political science", "public administration"))
333
+ _alias(["criminology", "criminal justice", "crime", "crime studies"], "category", "sociology and political science",
334
+ alternatives=("law", "pathology and forensic medicine", "social psychology"))
335
+ _alias(["public policy", "governance"], "category", "public administration",
336
+ alternatives=("political science and international relations", "health policy"))
337
+ _alias(["entrepreneurship"], "category", "business and international management",
338
+ alternatives=("strategy and management", "management of technology and innovation"))
339
+ _alias(["hr", "hrm"], "category", "organizational behavior and human resource management", "synonym")
340
+ _alias(["fintech"], "category", "finance",
341
+ alternatives=("information systems", "economics and econometrics"))
342
+ _alias(["teaching", "pedagogy", "higher education", "engineering education", "medical education"],
343
+ "category", "education",
344
+ alternatives=("e-learning", "developmental and educational psychology"))
345
+ _alias(["library science", "librarianship", "libraries", "library and information science",
346
+ "information science", "information studies"], "category", "library and information sciences", "synonym")
347
+ _alias(["legal studies", "jurisprudence"], "category", "law", "synonym")
348
+ _alias(["media", "media studies", "journalism", "mass communication"], "category", "communication",
349
+ alternatives=("cultural studies", "media technology"))
350
+ _alias(["religion", "theology", "islamic studies"], "category", "religious studies",
351
+ alternatives=("philosophy", "history"))
352
+ _alias(["ethics"], "category", "philosophy",
353
+ alternatives=("issues, ethics and legal aspects", "health policy"))
354
+ _alias(["business", "business studies"], "area", "business, management and accounting", "synonym",
355
+ alternatives=("business and international management", "strategy and management", "marketing"))
356
+ _alias(["economy", "banking"], "area", "economics, econometrics and finance",
357
+ alternatives=("economics and econometrics", "finance"))
358
+ _alias(["animal", "animals", "zoology", "animal science"], "category", "animal science and zoology", "synonym")
359
+ _alias(["ocean", "oceans", "ocean science", "ocean sciences"], "category", "oceanography", "synonym",
360
+ alternatives=("ocean engineering", "aquatic science"))
361
+ _alias(["marine"], "category", "aquatic science", alternatives=("oceanography", "ocean engineering"))
362
+ _alias(["film", "films", "cinema", "film studies", "theatre", "theater", "performing arts"], "category",
363
+ "visual arts and performing arts", alternatives=("communication", "cultural studies"))
364
+ _alias(["nano"], "category", "nanoscience and nanotechnology", "synonym")
365
+ _alias(["solar", "wind", "wind power", "solar power"], "category", "renewable energy, sustainability and the environment",
366
+ alternatives=("energy engineering and power technology",))
367
+ _alias(["english literature", "literary studies", "literature studies"], "category", "literature and literary theory",
368
+ alternatives=("linguistics and language", "cultural studies"))
369
+
370
+ # Words that never decide a nearest match on their own.
371
+ _GENERIC_SUBJECT_TOKENS = frozenset({
372
+ "science", "sciences", "scientific", "study", "studies", "research", "engineering",
373
+ "technology", "technologies", "theory", "applied", "general", "miscellaneous",
374
+ "system", "systems", "learning", "process", "processes", "analysis", "development",
375
+ "international", "policy", "care", "methods", "method", "advanced", "modern",
376
+ "introduction", "principles", "topics", "issues", "aspects", "new",
377
+ # Ambiguous on their own; left to the semantic classifier.
378
+ "planning", "behavior", "behaviour", "family", "design", "film", "films",
379
+ })
380
+
381
+
382
+ def _token_matches(query_token: str, name_token: str) -> bool:
383
+ if query_token == name_token:
384
+ return True
385
+ for a, b in ((query_token, name_token), (name_token, query_token)):
386
+ if a == b + "s" or a == b + "es":
387
+ return True
388
+ if a.endswith("ies") and a[:-3] + "y" == b:
389
+ return True
390
+ return False
391
+
392
+
393
+ def _covered(query_tokens: Set[str], name: str) -> Set[str]:
394
+ # Parenthetical qualifiers ("archeology (arts and humanities)") describe
395
+ # scope; they must not make a category look like a match for its parent.
396
+ name_tokens = _tokens(re.sub(r"\([^)]*\)", " ", name))
397
+ return {q for q in query_tokens if any(_token_matches(q, n) for n in name_tokens)}
398
+
399
+
400
+ def _is_misc(name: str) -> bool:
401
+ return name.endswith("(miscellaneous)")
402
+
403
+
404
+ def nearest_subject_term(text: str, allow_partial: bool = False) -> Optional[Dict[str, Any]]:
405
+ """Resolve free text to the nearest real area/category, or None.
406
+
407
+ Returns {"name", "kind" ("area"|"category"), "method", "match"
408
+ ("synonym"|"nearest"), "alternatives": [real category names]}.
409
+ ``method`` is "alias", "token_match" or "partial_token_match".
410
+ Exact names are not handled here (the caller checks them first).
411
+ """
412
+ vocab = subject_vocabulary()
413
+ cats, areas = vocab["categories"], vocab["areas"]
414
+
415
+ def real(kind: str, name: str) -> bool:
416
+ return name in (areas if kind == "area" else cats)
417
+
418
+ def result(name: str, kind: str, method: str, match: str, extra: List[str]) -> Dict[str, Any]:
419
+ alternatives: List[str] = []
420
+ for alt in extra:
421
+ if alt in cats and alt != name and alt not in alternatives:
422
+ alternatives.append(alt)
423
+ return {"name": name, "kind": kind, "method": method, "match": match,
424
+ "alternatives": alternatives[:6]}
425
+
426
+ key = _normalise_subject_match_text(text)
427
+ if not key:
428
+ return None
429
+
430
+ def related_suggestions() -> List[str]:
431
+ # Lexical suggestions that share a distinctive word with the input;
432
+ # a shared generic word ("science") is not a relationship.
433
+ words = {t for t in _tokens(key) if t not in _GENERIC_SUBJECT_TOKENS}
434
+ return [c for c in suggest_categories(text, limit=8) if _covered(words, c)]
435
+
436
+ alias = _SUBJECT_ALIASES.get(key)
437
+ if alias and real(alias["kind"], alias["name"]):
438
+ return result(alias["name"], alias["kind"], "alias", alias["match"],
439
+ list(alias["alternatives"]) + related_suggestions())
440
+
441
+ query = _tokens(key)
442
+ distinctive = {t for t in query if t not in _GENERIC_SUBJECT_TOKENS and len(t) > 1}
443
+ if not distinctive:
444
+ return None
445
+
446
+ # Full containment: every significant query word appears in the name.
447
+ cat_hits = [c for c in cats if _covered(query, c) == query]
448
+ area_hits = [a for a in areas if _covered(query, a) == query]
449
+ specific = [c for c in cat_hits if not _is_misc(c)]
450
+
451
+ def best_category(candidates: List[str]) -> str:
452
+ # Most precise name first (fewest extra words), then most journals.
453
+ return sorted(candidates, key=lambda c: (len(_tokens(c)), -cats.get(c, 0), c))[0]
454
+
455
+ def extra_words(name: str) -> int:
456
+ return len(_tokens(re.sub(r"\([^)]*\)", " ", name)) - query)
457
+
458
+ chosen: Optional[Tuple[str, str]] = None
459
+ if len(specific) == 1 and len(area_hits) == 1:
460
+ # One specific category and one area: take the tighter name, so
461
+ # "astronomy" -> "astronomy and astrophysics" but "chemical" ->
462
+ # area "chemical engineering", not "chemical health and safety".
463
+ if extra_words(specific[0]) <= extra_words(area_hits[0]):
464
+ chosen = (specific[0], "category")
465
+ else:
466
+ chosen = (area_hits[0], "area")
467
+ elif len(specific) == 1:
468
+ chosen = (specific[0], "category")
469
+ elif len(area_hits) == 1:
470
+ chosen = (area_hits[0], "area")
471
+ elif specific:
472
+ chosen = (best_category(specific), "category")
473
+ elif cat_hits:
474
+ chosen = (best_category(cat_hits), "category")
475
+ if chosen:
476
+ name, kind = chosen
477
+ extra = specific + [c for c in cat_hits if c not in specific] + related_suggestions()
478
+ return result(name, kind, "token_match", "nearest", extra)
479
+
480
+ if not allow_partial:
481
+ return None
482
+
483
+ # Partial: the best candidate sharing at least one distinctive word,
484
+ # weighted by how much of the query it covers. Used only as a last
485
+ # resort when the semantic classifier is unavailable or gave up.
486
+ weights = _subject_token_weights()
487
+ total = sum(weights.get(t, 1.0) for t in distinctive) or 1.0
488
+ ranked: List[Tuple[float, int, str, str]] = []
489
+ for kind, names in (("area", areas), ("category", cats)):
490
+ for name in names:
491
+ hit = _covered(distinctive, name)
492
+ if not hit:
493
+ continue
494
+ coverage = sum(weights.get(t, 1.0) for t in hit) / total
495
+ extra_words = len(_tokens(name) - set(hit))
496
+ # Prefer areas for a broad single-word match and specific
497
+ # categories otherwise; tie-break on fewer extra words.
498
+ ranked.append((-coverage, extra_words + (0 if kind == "area" else 1), name, kind))
499
+ if not ranked:
500
+ return None
501
+ ranked.sort()
502
+ best_cov = -ranked[0][0]
503
+ if best_cov < 0.5:
504
+ return None
505
+ _cov, _extra, name, kind = ranked[0]
506
+ extra = [n for _c, _e, n, k in ranked[1:12] if k == "category"] + related_suggestions()
507
+ return result(name, kind, "partial_token_match", "nearest", extra)
508
+
509
+
510
  _subject_token_df_cache: Optional[Dict[str, float]] = None
511
 
512
 
trusted_links.py CHANGED
@@ -1,283 +1,283 @@
1
- """Evidence-backed hyperlinks. No model links, paid search, or client credentials."""
2
- from __future__ import annotations
3
- import asyncio
4
- import csv
5
- import functools
6
- import json
7
- import http.client
8
- import ipaddress
9
- import re
10
- import socket
11
- import ssl
12
- import time
13
- from collections import OrderedDict
14
- from datetime import datetime, timezone
15
- from html.parser import HTMLParser
16
- from pathlib import Path
17
- from urllib.parse import urlsplit, urljoin
18
-
19
- ROOT = Path(__file__).parent
20
- # Publisher identity AND destination must match. Unknown publishers fail closed.
21
- PUBLISHERS = {
22
- 'elsevier': ('elsevier.com', 'sciencedirect.com'),
23
- 'springer': ('springer.com', 'springernature.com', 'nature.com', 'biomedcentral.com'),
24
- 'nature': ('nature.com', 'springernature.com'),
25
- 'wiley': ('wiley.com',), 'taylor': ('tandfonline.com', 'taylorandfrancis.com'),
26
- 'sage': ('sagepub.com',), 'ieee': ('ieee.org',), 'mdpi': ('mdpi.com',),
27
- 'frontiers': ('frontiersin.org',), 'plos': ('plos.org',),
28
- 'oxford': ('oup.com',), 'cambridge': ('cambridge.org',),
29
- 'american chemical': ('acs.org',), 'royal society of chemistry': ('rsc.org',),
30
- 'iop': ('iop.org',), 'acm': ('acm.org',), 'american physical': ('aps.org',),
31
- }
32
-
33
- def safe_url(value):
34
- try:
35
- if not isinstance(value, str) or len(value) > 2048 or re.search(r'[\s\\\x00-\x1f]', value): return None
36
- p = urlsplit(value)
37
- if p.scheme != 'https' or not p.hostname or p.username or p.password or p.port not in (None,443): return None
38
- h = p.hostname.lower()
39
- if '.' not in h or h.endswith(('.local', '.internal', '.localhost')): return None
40
- try:
41
- if not ipaddress.ip_address(h).is_global: return None
42
- except ValueError: pass
43
- return value
44
- except ValueError: return None
45
-
46
- def allowed(url, domains):
47
- return bool(safe_url(url) and any(urlsplit(url).hostname == d or urlsplit(url).hostname.endswith('.'+d) for d in domains))
48
-
49
- def stamp(): return datetime.now(timezone.utc).isoformat(timespec='seconds')
50
- def unavailable(): return {'url': None, 'status':'unavailable', 'source':None, 'reviewed_at':None}
51
-
52
- def canonical(j):
53
- from journal_lookup import get_index
54
- from researchbee_journals import norm_issn
55
- idx = get_index()
56
- if not idx: return None
57
- ident = j.get('identity_verification') or {}
58
- sid = j.get('scopus_id') or ident.get('scopus_id')
59
- issn = j.get('issn')
60
- rec = idx.lookup_exact(scopus_id=str(sid)) if sid else idx.lookup_exact(issn=issn) if issn else idx.lookup_exact(title=j.get('name',''))
61
- if rec and issn and norm_issn(issn) not in {norm_issn(rec.issn_e),norm_issn(rec.issn_print)}: return None
62
- if rec and j.get('name'):
63
- if idx.normalise_identity_title(j['name']) != idx.normalise_identity_title(rec.title): return None
64
- return rec
65
-
66
- @functools.lru_cache(maxsize=1)
67
- def metric_data():
68
- from journal_lookup import get_index
69
- from scimago_index import parse_category_quartiles
70
- idx = get_index()
71
- names = {}
72
- if idx:
73
- for row in idx.con.execute('SELECT code, name FROM asjc'):
74
- names.setdefault(row['name'].casefold(),set()).add(str(row['code']))
75
- records = {}
76
- with (ROOT/'scimagojr_2025.csv').open(encoding='latin-1',newline='') as f:
77
- reader=csv.DictReader(f)
78
- years=[re.fullmatch(r'Total Docs\. \((\d{4})\)',h) for h in reader.fieldnames]
79
- year=next((m.group(1) for m in years if m),None)
80
- for row in reader:
81
- fields=[]
82
- sid=row.get('Sourceid','')
83
- profile=f'https://www.scimagojr.com/journalsearch.php?q={sid}&tip=sid' if sid.isdigit() else None
84
- parsed=parse_category_quartiles(row.get('Categories',''))
85
- labels={}
86
- for part in row.get('Categories','').split(';'):
87
- label=re.sub(r'\s*\(Q[1-4]\)\s*$','',part.strip(),flags=re.I)
88
- if label: labels[' '.join(label.split()).casefold()]=label
89
- for name,label in labels.items():
90
- q=parsed.get(name)
91
- codes=names.get(name.casefold(),set())
92
- code=next(iter(codes)) if len(codes)==1 else None
93
- fields.append({'field':label,'quartile':q,'code':code,'source':'SCImago SJR','year':year,
94
- 'category_url':f'https://www.scimagojr.com/journalrank.php?category={code}&year={year}' if code and year else None,
95
- 'profile_url':profile})
96
- records[sid]={'fields':fields,'year':year,'profile_url':profile,'issns':set(re.findall(r'[0-9X]{8}',row.get('Issn','')))}
97
- return records
98
-
99
- @functools.lru_cache(maxsize=1)
100
- def curated_records():
101
- try:
102
- return json.loads((ROOT/'journal_links_curated.json').read_text())
103
- except (OSError, ValueError): return {}
104
-
105
- def curated_guidance(rec):
106
- entry = curated_records().get(str(rec.scopus_id))
107
- if not isinstance(entry,dict): return None
108
- norm=lambda v: re.sub('[^0-9X]','',str(v).upper())
109
- if not ({norm(rec.issn_e),norm(rec.issn_print)}-{''}) & {norm(v) for v in entry.get('issns',[])}: return None
110
- if not safe_url(entry.get('url')) or not safe_url(entry.get('source')): return None
111
- domains=tuple(d for key,ds in PUBLISHERS.items() if key in ((rec.publisher_group or '')+' '+(rec.publisher or '')).lower() for d in ds)
112
- if not allowed(entry['url'],domains) or not allowed(entry['source'],domains): return None
113
- if entry.get('scope') not in ('journal-specific','general-publisher'): return None
114
- # Dates are retained exactly, never populated with this software release date.
115
- return {**entry,'status':'curated'}
116
-
117
- _cache=OrderedDict()
118
- _metadata_cache=OrderedDict()
119
- _locks={}
120
- _gate=asyncio.Semaphore(4)
121
-
122
- def cached(key):
123
- item=_cache.get(key)
124
- if item and time.monotonic()<item[0]:
125
- _cache.move_to_end(key); return item[1]
126
- if item: del _cache[key]
127
- return None
128
-
129
- def cache_put(key,value):
130
- _cache[key]=(time.monotonic()+(86400 if value['status']=='checked' else 3600),value)
131
- while len(_cache)>512: _cache.popitem(last=False)
132
-
133
- async def journal_links(j, fetch_metadata=True):
134
- import openalex
135
- rec=canonical(j)
136
- result={'homepage':unavailable(),'guidelines':unavailable(),'can_discover':bool(rec)}
137
- j['journal_links']=result
138
- if not rec: return result
139
- result['canonical_id']=rec.scopus_id
140
- metric=metric_data().get(str(rec.scimago_id)) or {}
141
- if not ({re.sub('[^0-9X]','',i or '') for i in (rec.issn_e,rec.issn_print)} & metric.get('issns',set())): metric={}
142
- fields=metric.get('fields',[])
143
- if not metric:
144
- j['quartile']=None
145
- if isinstance(j.get('ranking'),dict):
146
- j['ranking'].update(quartile=None, verification_status='Not available')
147
- j['quartiles_by_field']=fields
148
- if isinstance(j.get('ranking'),dict):
149
- j['ranking'].update(source='SCImago SJR' if metric else 'Not available',year=metric.get('year'),quartiles_by_field=fields)
150
- vl=j.setdefault('verify_links',{})
151
- vl['scimago']=metric.get('profile_url')
152
- vl['scopus']=f'https://www.scopus.com/sourceid/{rec.scopus_id}' if str(rec.scopus_id).isdigit() else None
153
- oa=j.get('openalex') or {}
154
- expected={re.sub('[^0-9X]','',i or '') for i in (rec.issn_e,rec.issn_print)}-{''}
155
- norm=lambda s:re.sub('[^0-9X]','',str(s).upper())
156
- matched=lambda v:bool(expected & {norm(s) for s in [v.get('issn_l'),*(v.get('issns') or [])]})
157
- if not matched(oa) and fetch_metadata:
158
- item=_metadata_cache.get(rec.scopus_id)
159
- if item and time.monotonic()<item[0]:
160
- oa=item[1]
161
- _metadata_cache.move_to_end(rec.scopus_id)
162
- else:
163
- oa=await openalex.enrich_journal(rec.title,rec.issn_e or rec.issn_print) or {}
164
- _metadata_cache[rec.scopus_id]=(time.monotonic()+3600,oa)
165
- while len(_metadata_cache)>512: _metadata_cache.popitem(last=False)
166
- if matched(oa) and safe_url(oa.get('homepage_url')):
167
- result['homepage']={'url':oa['homepage_url'],'status':'metadata-only','source':oa.get('openalex_id'),'source_updated_at':oa.get('source_updated_at'),'retrieved_at':oa.get('retrieved_at'),'reviewed_at':None}
168
- result['guidelines']=curated_guidance(rec) or cached(rec.scopus_id) or unavailable()
169
- return result
170
-
171
- class Anchors(HTMLParser):
172
- def __init__(self): super().__init__(); self.links=[]; self.current=None; self.text=[]
173
- def handle_starttag(self,tag,attrs):
174
- if tag=='a': self.current=dict(attrs).get('href'); self.text=[]
175
- def handle_data(self,data):
176
- if self.current: self.text.append(data)
177
- def handle_endtag(self,tag):
178
- if tag=='a' and self.current:
179
- self.links.append((self.current,' '.join(self.text))); self.current=None
180
-
181
- class PinnedHTTPS(http.client.HTTPSConnection):
182
- def __init__(self,host,ip): super().__init__(host,timeout=3,context=ssl.create_default_context()); self.ip=ip
183
- def connect(self):
184
- self.sock=self._context.wrap_socket(socket.create_connection((self.ip,443),self.timeout),server_hostname=self.host)
185
-
186
- def fetch_page(url,domains):
187
- """At most 3 requests, public DNS only, pinned IP + original TLS SNI/Host."""
188
- deadline=time.monotonic()+6
189
- for redirect in range(3):
190
- if time.monotonic()>deadline: raise TimeoutError('Page deadline')
191
- if not allowed(url,domains): raise ValueError('Destination not allowed')
192
- p=urlsplit(url)
193
- ips={x[4][0] for x in socket.getaddrinfo(p.hostname,443,type=socket.SOCK_STREAM)}
194
- if not ips or any(not ipaddress.ip_address(ip).is_global for ip in ips): raise ValueError('Unsafe DNS')
195
- conn=PinnedHTTPS(p.hostname,sorted(ips)[0])
196
- try:
197
- conn.request('GET',(p.path or '/')+('?' + p.query if p.query else ''),headers={'User-Agent':'ResearchBee/1.9 (library@ku.ac.ae)','Accept':'text/html','Accept-Encoding':'identity'})
198
- response=conn.getresponse()
199
- if response.status in (301,302,303,307,308):
200
- if redirect==2: raise ValueError('Redirect limit')
201
- url=urljoin(url,response.getheader('Location','')); continue
202
- if response.status!=200 or 'text/html' not in (response.getheader('Content-Type') or ''): raise ValueError('Page unavailable')
203
- chunks=[]; total=0
204
- while total<=512000:
205
- if time.monotonic()>deadline: raise TimeoutError('Page deadline')
206
- chunk=response.read1(min(16384,512001-total))
207
- if not chunk: break
208
- chunks.append(chunk); total+=len(chunk)
209
- data=b''.join(chunks)
210
- if len(data)>512000: raise ValueError('Page too large')
211
- return url,data.decode('utf-8',errors='replace')
212
- finally: conn.close()
213
- raise ValueError('Redirect limit')
214
-
215
- async def discover(j):
216
- rec=canonical(j)
217
- if not rec: return {'homepage':unavailable(),'guidelines':unavailable(),'can_discover':False}
218
- async with _gate:
219
- lock=_locks.setdefault(rec.scopus_id,asyncio.Lock())
220
- try:
221
- async with lock:
222
- links=await journal_links(j)
223
- prior=curated_guidance(rec) or cached(rec.scopus_id)
224
- if prior: links['guidelines']=prior; return links
225
- result=unavailable()
226
- result['attempted_at']=stamp()
227
- result['message']='Author guidance unavailable; use the known journal homepage if available.'
228
- domains=tuple(d for key,ds in PUBLISHERS.items() if key in ((rec.publisher_group or '')+' '+(rec.publisher or '')).lower() for d in ds)
229
- home=links['homepage'].get('url')
230
- try:
231
- if not allowed(home,domains): raise ValueError('Official publisher not established')
232
- async with asyncio.timeout(15):
233
- page,html=await asyncio.to_thread(fetch_page,home,domains)
234
- # Require journal identity on the publisher page, not just its domain.
235
- from researchbee_journals import JournalIndex
236
- text=JournalIndex.normalise_identity_title(re.sub('<[^>]+>',' ',html))
237
- title=JournalIndex.normalise_identity_title(rec.title)
238
- if title not in text and not any(i and i.replace('-','') in re.sub('[^0-9X]','',html.upper()) for i in (rec.issn_e,rec.issn_print)):
239
- raise ValueError('Journal page identity missing')
240
- parser=Anchors(); parser.feed(html)
241
- candidates=[]
242
- for href,label in parser.links:
243
- if re.search(r'author.{0,25}(guid|instruction|information)|guide for authors|submission guidelines',label,re.I):
244
- target=urljoin(page,href)
245
- if allowed(target,domains): candidates.append(target)
246
- if not candidates: raise ValueError('No actual guidance link')
247
- target,body=await asyncio.to_thread(fetch_page,candidates[0],domains)
248
- headings = ' '.join(re.findall(r'<(?:title|h1)\b[^>]*>(.*?)</(?:title|h1)>', body, flags=re.I|re.S))
249
- home_parts=[p for p in urlsplit(page).path.split('/') if p]
250
- journal_slug=home_parts[-1] if home_parts else ''
251
- target_parts=urlsplit(target).path.split('/')
252
- specific=bool(journal_slug and journal_slug in target_parts and title in JournalIndex.normalise_identity_title(re.sub('<[^>]+>',' ',headings)))
253
- result={'url':target,'status':'checked','scope':'journal-specific' if specific else 'general-publisher',
254
- 'source':page,'reviewed_at':stamp(),'message':'Journal-specific author instructions' if specific else 'General publisher guidance; journal-specific instructions were not established.'}
255
- except Exception as exc:
256
- result['failure_reason']=type(exc).__name__
257
- cache_put(rec.scopus_id,result); links['guidelines']=result
258
- return links
259
- finally:
260
- # Keep locks only while waiters exist; global gate bounds active tasks.
261
- if not lock.locked() and not getattr(lock,'_waiters',None): _locks.pop(rec.scopus_id,None)
262
-
263
- async def enrich_response(result):
264
- jobs=[]
265
- def walk(value):
266
- if isinstance(value,dict):
267
- if value.get('name') and ('ranking' in value or 'quartiles_by_field' in value or 'identity_verification' in value): jobs.append(value)
268
- for child in list(value.values()): walk(child)
269
- elif isinstance(value,list):
270
- for child in value: walk(child)
271
- walk(result)
272
- semaphore=asyncio.Semaphore(5)
273
- async def one(j):
274
- async with semaphore:
275
- try: await journal_links(j)
276
- except Exception: j['journal_links']={'homepage':unavailable(),'guidelines':unavailable(),'can_discover':False}
277
- await asyncio.gather(*(one(j) for j in jobs))
278
- return result
279
-
280
- def with_links(fn):
281
- @functools.wraps(fn)
282
- async def wrapped(*args,**kwargs): return await enrich_response(await fn(*args,**kwargs))
283
- return wrapped
 
1
+ """Evidence-backed hyperlinks. No model links, paid search, or client credentials."""
2
+ from __future__ import annotations
3
+ import asyncio
4
+ import csv
5
+ import functools
6
+ import json
7
+ import http.client
8
+ import ipaddress
9
+ import re
10
+ import socket
11
+ import ssl
12
+ import time
13
+ from collections import OrderedDict
14
+ from datetime import datetime, timezone
15
+ from html.parser import HTMLParser
16
+ from pathlib import Path
17
+ from urllib.parse import urlsplit, urljoin
18
+
19
+ ROOT = Path(__file__).parent
20
+ # Publisher identity AND destination must match. Unknown publishers fail closed.
21
+ PUBLISHERS = {
22
+ 'elsevier': ('elsevier.com', 'sciencedirect.com'),
23
+ 'springer': ('springer.com', 'springernature.com', 'nature.com', 'biomedcentral.com'),
24
+ 'nature': ('nature.com', 'springernature.com'),
25
+ 'wiley': ('wiley.com',), 'taylor': ('tandfonline.com', 'taylorandfrancis.com'),
26
+ 'sage': ('sagepub.com',), 'ieee': ('ieee.org',), 'mdpi': ('mdpi.com',),
27
+ 'frontiers': ('frontiersin.org',), 'plos': ('plos.org',),
28
+ 'oxford': ('oup.com',), 'cambridge': ('cambridge.org',),
29
+ 'american chemical': ('acs.org',), 'royal society of chemistry': ('rsc.org',),
30
+ 'iop': ('iop.org',), 'acm': ('acm.org',), 'american physical': ('aps.org',),
31
+ }
32
+
33
+ def safe_url(value):
34
+ try:
35
+ if not isinstance(value, str) or len(value) > 2048 or re.search(r'[\s\\\x00-\x1f]', value): return None
36
+ p = urlsplit(value)
37
+ if p.scheme != 'https' or not p.hostname or p.username or p.password or p.port not in (None,443): return None
38
+ h = p.hostname.lower()
39
+ if '.' not in h or h.endswith(('.local', '.internal', '.localhost')): return None
40
+ try:
41
+ if not ipaddress.ip_address(h).is_global: return None
42
+ except ValueError: pass
43
+ return value
44
+ except ValueError: return None
45
+
46
+ def allowed(url, domains):
47
+ return bool(safe_url(url) and any(urlsplit(url).hostname == d or urlsplit(url).hostname.endswith('.'+d) for d in domains))
48
+
49
+ def stamp(): return datetime.now(timezone.utc).isoformat(timespec='seconds')
50
+ def unavailable(): return {'url': None, 'status':'unavailable', 'source':None, 'reviewed_at':None}
51
+
52
+ def canonical(j):
53
+ from journal_lookup import get_index
54
+ from researchbee_journals import norm_issn
55
+ idx = get_index()
56
+ if not idx: return None
57
+ ident = j.get('identity_verification') or {}
58
+ sid = j.get('scopus_id') or ident.get('scopus_id')
59
+ issn = j.get('issn')
60
+ rec = idx.lookup_exact(scopus_id=str(sid)) if sid else idx.lookup_exact(issn=issn) if issn else idx.lookup_exact(title=j.get('name',''))
61
+ if rec and issn and norm_issn(issn) not in {norm_issn(rec.issn_e),norm_issn(rec.issn_print)}: return None
62
+ if rec and j.get('name'):
63
+ if idx.normalise_identity_title(j['name']) != idx.normalise_identity_title(rec.title): return None
64
+ return rec
65
+
66
+ @functools.lru_cache(maxsize=1)
67
+ def metric_data():
68
+ from journal_lookup import get_index
69
+ from scimago_index import parse_category_quartiles
70
+ idx = get_index()
71
+ names = {}
72
+ if idx:
73
+ for row in idx.con.execute('SELECT code, name FROM asjc'):
74
+ names.setdefault(row['name'].casefold(),set()).add(str(row['code']))
75
+ records = {}
76
+ with (ROOT/'scimagojr_2025.csv').open(encoding='latin-1',newline='') as f:
77
+ reader=csv.DictReader(f)
78
+ years=[re.fullmatch(r'Total Docs\. \((\d{4})\)',h) for h in reader.fieldnames]
79
+ year=next((m.group(1) for m in years if m),None)
80
+ for row in reader:
81
+ fields=[]
82
+ sid=row.get('Sourceid','')
83
+ profile=f'https://www.scimagojr.com/journalsearch.php?q={sid}&tip=sid' if sid.isdigit() else None
84
+ parsed=parse_category_quartiles(row.get('Categories',''))
85
+ labels={}
86
+ for part in row.get('Categories','').split(';'):
87
+ label=re.sub(r'\s*\(Q[1-4]\)\s*$','',part.strip(),flags=re.I)
88
+ if label: labels[' '.join(label.split()).casefold()]=label
89
+ for name,label in labels.items():
90
+ q=parsed.get(name)
91
+ codes=names.get(name.casefold(),set())
92
+ code=next(iter(codes)) if len(codes)==1 else None
93
+ fields.append({'field':label,'quartile':q,'code':code,'source':'SCImago SJR','year':year,
94
+ 'category_url':f'https://www.scimagojr.com/journalrank.php?category={code}&year={year}' if code and year else None,
95
+ 'profile_url':profile})
96
+ records[sid]={'fields':fields,'year':year,'profile_url':profile,'issns':set(re.findall(r'[0-9X]{8}',row.get('Issn','')))}
97
+ return records
98
+
99
+ @functools.lru_cache(maxsize=1)
100
+ def curated_records():
101
+ try:
102
+ return json.loads((ROOT/'journal_links_curated.json').read_text())
103
+ except (OSError, ValueError): return {}
104
+
105
+ def curated_guidance(rec):
106
+ entry = curated_records().get(str(rec.scopus_id))
107
+ if not isinstance(entry,dict): return None
108
+ norm=lambda v: re.sub('[^0-9X]','',str(v).upper())
109
+ if not ({norm(rec.issn_e),norm(rec.issn_print)}-{''}) & {norm(v) for v in entry.get('issns',[])}: return None
110
+ if not safe_url(entry.get('url')) or not safe_url(entry.get('source')): return None
111
+ domains=tuple(d for key,ds in PUBLISHERS.items() if key in ((rec.publisher_group or '')+' '+(rec.publisher or '')).lower() for d in ds)
112
+ if not allowed(entry['url'],domains) or not allowed(entry['source'],domains): return None
113
+ if entry.get('scope') not in ('journal-specific','general-publisher'): return None
114
+ # Dates are retained exactly, never populated with this software release date.
115
+ return {**entry,'status':'curated'}
116
+
117
+ _cache=OrderedDict()
118
+ _metadata_cache=OrderedDict()
119
+ _locks={}
120
+ _gate=asyncio.Semaphore(4)
121
+
122
+ def cached(key):
123
+ item=_cache.get(key)
124
+ if item and time.monotonic()<item[0]:
125
+ _cache.move_to_end(key); return item[1]
126
+ if item: del _cache[key]
127
+ return None
128
+
129
+ def cache_put(key,value):
130
+ _cache[key]=(time.monotonic()+(86400 if value['status']=='checked' else 3600),value)
131
+ while len(_cache)>512: _cache.popitem(last=False)
132
+
133
+ async def journal_links(j, fetch_metadata=True):
134
+ import openalex
135
+ rec=canonical(j)
136
+ result={'homepage':unavailable(),'guidelines':unavailable(),'can_discover':bool(rec)}
137
+ j['journal_links']=result
138
+ if not rec: return result
139
+ result['canonical_id']=rec.scopus_id
140
+ metric=metric_data().get(str(rec.scimago_id)) or {}
141
+ if not ({re.sub('[^0-9X]','',i or '') for i in (rec.issn_e,rec.issn_print)} & metric.get('issns',set())): metric={}
142
+ fields=metric.get('fields',[])
143
+ if not metric:
144
+ j['quartile']=None
145
+ if isinstance(j.get('ranking'),dict):
146
+ j['ranking'].update(quartile=None, verification_status='Not available')
147
+ j['quartiles_by_field']=fields
148
+ if isinstance(j.get('ranking'),dict):
149
+ j['ranking'].update(source='SCImago SJR' if metric else 'Not available',year=metric.get('year'),quartiles_by_field=fields)
150
+ vl=j.setdefault('verify_links',{})
151
+ vl['scimago']=metric.get('profile_url')
152
+ vl['scopus']=f'https://www.scopus.com/sourceid/{rec.scopus_id}' if str(rec.scopus_id).isdigit() else None
153
+ oa=j.get('openalex') or {}
154
+ expected={re.sub('[^0-9X]','',i or '') for i in (rec.issn_e,rec.issn_print)}-{''}
155
+ norm=lambda s:re.sub('[^0-9X]','',str(s).upper())
156
+ matched=lambda v:bool(expected & {norm(s) for s in [v.get('issn_l'),*(v.get('issns') or [])]})
157
+ if not matched(oa) and fetch_metadata:
158
+ item=_metadata_cache.get(rec.scopus_id)
159
+ if item and time.monotonic()<item[0]:
160
+ oa=item[1]
161
+ _metadata_cache.move_to_end(rec.scopus_id)
162
+ else:
163
+ oa=await openalex.enrich_journal(rec.title,rec.issn_e or rec.issn_print) or {}
164
+ _metadata_cache[rec.scopus_id]=(time.monotonic()+3600,oa)
165
+ while len(_metadata_cache)>512: _metadata_cache.popitem(last=False)
166
+ if matched(oa) and safe_url(oa.get('homepage_url')):
167
+ result['homepage']={'url':oa['homepage_url'],'status':'metadata-only','source':oa.get('openalex_id'),'source_updated_at':oa.get('source_updated_at'),'retrieved_at':oa.get('retrieved_at'),'reviewed_at':None}
168
+ result['guidelines']=curated_guidance(rec) or cached(rec.scopus_id) or unavailable()
169
+ return result
170
+
171
+ class Anchors(HTMLParser):
172
+ def __init__(self): super().__init__(); self.links=[]; self.current=None; self.text=[]
173
+ def handle_starttag(self,tag,attrs):
174
+ if tag=='a': self.current=dict(attrs).get('href'); self.text=[]
175
+ def handle_data(self,data):
176
+ if self.current: self.text.append(data)
177
+ def handle_endtag(self,tag):
178
+ if tag=='a' and self.current:
179
+ self.links.append((self.current,' '.join(self.text))); self.current=None
180
+
181
+ class PinnedHTTPS(http.client.HTTPSConnection):
182
+ def __init__(self,host,ip): super().__init__(host,timeout=3,context=ssl.create_default_context()); self.ip=ip
183
+ def connect(self):
184
+ self.sock=self._context.wrap_socket(socket.create_connection((self.ip,443),self.timeout),server_hostname=self.host)
185
+
186
+ def fetch_page(url,domains):
187
+ """At most 3 requests, public DNS only, pinned IP + original TLS SNI/Host."""
188
+ deadline=time.monotonic()+6
189
+ for redirect in range(3):
190
+ if time.monotonic()>deadline: raise TimeoutError('Page deadline')
191
+ if not allowed(url,domains): raise ValueError('Destination not allowed')
192
+ p=urlsplit(url)
193
+ ips={x[4][0] for x in socket.getaddrinfo(p.hostname,443,type=socket.SOCK_STREAM)}
194
+ if not ips or any(not ipaddress.ip_address(ip).is_global for ip in ips): raise ValueError('Unsafe DNS')
195
+ conn=PinnedHTTPS(p.hostname,sorted(ips)[0])
196
+ try:
197
+ conn.request('GET',(p.path or '/')+('?' + p.query if p.query else ''),headers={'User-Agent':'ResearchBee/1.9 (library@ku.ac.ae)','Accept':'text/html','Accept-Encoding':'identity'})
198
+ response=conn.getresponse()
199
+ if response.status in (301,302,303,307,308):
200
+ if redirect==2: raise ValueError('Redirect limit')
201
+ url=urljoin(url,response.getheader('Location','')); continue
202
+ if response.status!=200 or 'text/html' not in (response.getheader('Content-Type') or ''): raise ValueError('Page unavailable')
203
+ chunks=[]; total=0
204
+ while total<=512000:
205
+ if time.monotonic()>deadline: raise TimeoutError('Page deadline')
206
+ chunk=response.read1(min(16384,512001-total))
207
+ if not chunk: break
208
+ chunks.append(chunk); total+=len(chunk)
209
+ data=b''.join(chunks)
210
+ if len(data)>512000: raise ValueError('Page too large')
211
+ return url,data.decode('utf-8',errors='replace')
212
+ finally: conn.close()
213
+ raise ValueError('Redirect limit')
214
+
215
+ async def discover(j):
216
+ rec=canonical(j)
217
+ if not rec: return {'homepage':unavailable(),'guidelines':unavailable(),'can_discover':False}
218
+ async with _gate:
219
+ lock=_locks.setdefault(rec.scopus_id,asyncio.Lock())
220
+ try:
221
+ async with lock:
222
+ links=await journal_links(j)
223
+ prior=curated_guidance(rec) or cached(rec.scopus_id)
224
+ if prior: links['guidelines']=prior; return links
225
+ result=unavailable()
226
+ result['attempted_at']=stamp()
227
+ result['message']='Author guidance unavailable; use the known journal homepage if available.'
228
+ domains=tuple(d for key,ds in PUBLISHERS.items() if key in ((rec.publisher_group or '')+' '+(rec.publisher or '')).lower() for d in ds)
229
+ home=links['homepage'].get('url')
230
+ try:
231
+ if not allowed(home,domains): raise ValueError('Official publisher not established')
232
+ async with asyncio.timeout(15):
233
+ page,html=await asyncio.to_thread(fetch_page,home,domains)
234
+ # Require journal identity on the publisher page, not just its domain.
235
+ from researchbee_journals import JournalIndex
236
+ text=JournalIndex.normalise_identity_title(re.sub('<[^>]+>',' ',html))
237
+ title=JournalIndex.normalise_identity_title(rec.title)
238
+ if title not in text and not any(i and i.replace('-','') in re.sub('[^0-9X]','',html.upper()) for i in (rec.issn_e,rec.issn_print)):
239
+ raise ValueError('Journal page identity missing')
240
+ parser=Anchors(); parser.feed(html)
241
+ candidates=[]
242
+ for href,label in parser.links:
243
+ if re.search(r'author.{0,25}(guid|instruction|information)|guide for authors|submission guidelines',label,re.I):
244
+ target=urljoin(page,href)
245
+ if allowed(target,domains): candidates.append(target)
246
+ if not candidates: raise ValueError('No actual guidance link')
247
+ target,body=await asyncio.to_thread(fetch_page,candidates[0],domains)
248
+ headings = ' '.join(re.findall(r'<(?:title|h1)\b[^>]*>(.*?)</(?:title|h1)>', body, flags=re.I|re.S))
249
+ home_parts=[p for p in urlsplit(page).path.split('/') if p]
250
+ journal_slug=home_parts[-1] if home_parts else ''
251
+ target_parts=urlsplit(target).path.split('/')
252
+ specific=bool(journal_slug and journal_slug in target_parts and title in JournalIndex.normalise_identity_title(re.sub('<[^>]+>',' ',headings)))
253
+ result={'url':target,'status':'checked','scope':'journal-specific' if specific else 'general-publisher',
254
+ 'source':page,'reviewed_at':stamp(),'message':'Journal-specific author instructions' if specific else 'General publisher guidance; journal-specific instructions were not established.'}
255
+ except Exception as exc:
256
+ result['failure_reason']=type(exc).__name__
257
+ cache_put(rec.scopus_id,result); links['guidelines']=result
258
+ return links
259
+ finally:
260
+ # Keep locks only while waiters exist; global gate bounds active tasks.
261
+ if not lock.locked() and not getattr(lock,'_waiters',None): _locks.pop(rec.scopus_id,None)
262
+
263
+ async def enrich_response(result):
264
+ jobs=[]
265
+ def walk(value):
266
+ if isinstance(value,dict):
267
+ if value.get('name') and ('ranking' in value or 'quartiles_by_field' in value or 'identity_verification' in value): jobs.append(value)
268
+ for child in list(value.values()): walk(child)
269
+ elif isinstance(value,list):
270
+ for child in value: walk(child)
271
+ walk(result)
272
+ semaphore=asyncio.Semaphore(5)
273
+ async def one(j):
274
+ async with semaphore:
275
+ try: await journal_links(j)
276
+ except Exception: j['journal_links']={'homepage':unavailable(),'guidelines':unavailable(),'can_discover':False}
277
+ await asyncio.gather(*(one(j) for j in jobs))
278
+ return result
279
+
280
+ def with_links(fn):
281
+ @functools.wraps(fn)
282
+ async def wrapped(*args,**kwargs): return await enrich_response(await fn(*args,**kwargs))
283
+ return wrapped