Spaces:
Running
Running
Upload 7 files
Browse files- README-DEPLOY.md +22 -18
- README.md +17 -2
- RELEASE_1.8.1.md +54 -0
- app.py +148 -22
- openpolicyfinder.py +1 -1
- prompts.py +44 -27
- scimago_index.py +128 -24
README-DEPLOY.md
CHANGED
|
@@ -1,25 +1,22 @@
|
|
| 1 |
-
# ResearchBee v1.8.
|
| 2 |
|
| 3 |
-
This
|
| 4 |
|
| 5 |
-
## 1. Files changed or added in v1.8.
|
| 6 |
|
| 7 |
| File | Action | Purpose |
|
| 8 |
|---|---|---|
|
| 9 |
-
| `app.py` | Replace |
|
| 10 |
-
| `
|
| 11 |
-
| `
|
| 12 |
-
| `
|
| 13 |
-
| `
|
| 14 |
-
| `openpolicyfinder.py` | Replace | v1.8 client identity and shared journal-policy integration |
|
| 15 |
-
| `prompts.py` | Replace | Closed-vocabulary profiling and closed-list manuscript reranking |
|
| 16 |
-
| `README.md` | Replace | v1.8 architecture and evidence boundaries |
|
| 17 |
| `README-DEPLOY.md` | Replace | This deployment guide |
|
| 18 |
-
| `RELEASE_1.8.
|
| 19 |
-
| `ResearchBee_LibGuides.html` | Replace in LibGuides only | Unified
|
| 20 |
-
| `tests/` | Replace/add for source repository | Regression coverage for both
|
| 21 |
|
| 22 |
-
Existing data files such as `journals.db` and `scimagojr_2025.csv` are not altered
|
| 23 |
|
| 24 |
## 2. Hugging Face Space secrets
|
| 25 |
|
|
@@ -46,7 +43,7 @@ OPF_API_MAX_POLICY_HYDRATIONS=6
|
|
| 46 |
|
| 47 |
## 3. Backend deployment
|
| 48 |
|
| 49 |
-
Upload the changed backend files to the repository root. Allow the
|
| 50 |
|
| 51 |
After the Space rebuilds, check:
|
| 52 |
|
|
@@ -56,7 +53,7 @@ GET /api/health
|
|
| 56 |
|
| 57 |
Expected indicators:
|
| 58 |
|
| 59 |
-
- `version: 1.8.
|
| 60 |
- journal index loaded with an active-record count
|
| 61 |
- Open Policy Finder configured when its secret is present
|
| 62 |
- knowledge base loaded
|
|
@@ -193,13 +190,20 @@ node --check /tmp/researchbee.js
|
|
| 193 |
6. Use **Review or correct inferred details**, amend a field, and resubmit.
|
| 194 |
7. Compare and export two to five journals.
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
### Safety and regression
|
| 197 |
|
| 198 |
1. Disconnect or remove the OPF key in a test environment. Policy results must remain unavailable/unknown rather than guessed.
|
| 199 |
2. Temporarily make the journal database unavailable. Both journal routes must return a safe error rather than model-created titles.
|
| 200 |
3. Confirm the DOI-based licence workflow still uses OA.Works and Khazna independently.
|
| 201 |
4. Confirm repository finder, chat, service introduction, language controls, mobile tabs, cover-letter generation, and related works still operate.
|
| 202 |
-
5. Confirm `/api/health` reports `1.8.
|
| 203 |
|
| 204 |
## 9. Known evidence limitations
|
| 205 |
|
|
|
|
| 1 |
+
# ResearchBee v1.8.1 — deployment and maintenance guide
|
| 2 |
|
| 3 |
+
This patch aligns the LibGuides display with the shared, database-first journal-discovery system introduced in v1.8.0 and strengthens subject correction and nearest-category mapping. The same filters, policy checks, scoring, evidence, comparison, and export remain available in **Browse by subject** and **Analyse manuscript**.
|
| 4 |
|
| 5 |
+
## 1. Files changed or added in v1.8.1
|
| 6 |
|
| 7 |
| File | Action | Purpose |
|
| 8 |
|---|---|---|
|
| 9 |
+
| `app.py` | Replace | Version alignment, closed-vocabulary spelling repair, validated nearest-category fallback, and mapping provenance |
|
| 10 |
+
| `scimago_index.py` | Replace | Conservative spelling matcher and improved deterministic category suggestions |
|
| 11 |
+
| `prompts.py` | Replace | Explicit synonym, spelling-correction, nearest-category, and no-match decisions constrained to the approved vocabulary |
|
| 12 |
+
| `openpolicyfinder.py` | Replace | v1.8.1 client identity |
|
| 13 |
+
| `README.md` | Replace | v1.8.1 behavior and evidence boundaries |
|
|
|
|
|
|
|
|
|
|
| 14 |
| `README-DEPLOY.md` | Replace | This deployment guide |
|
| 15 |
+
| `RELEASE_1.8.1.md` | Add | Patch release notes and exact migration steps |
|
| 16 |
+
| `ResearchBee_LibGuides.html` | Replace in LibGuides only | Unified comparison/OA rendering, schema-mismatch protection, and transparent subject-mapping notices |
|
| 17 |
+
| `tests/` | Replace/add for source repository | Regression coverage for both journal modes and subject mapping |
|
| 18 |
|
| 19 |
+
Existing database and data files such as `journals.db` and `scimagojr_2025.csv` are not altered and should remain in the Space.
|
| 20 |
|
| 21 |
## 2. Hugging Face Space secrets
|
| 22 |
|
|
|
|
| 43 |
|
| 44 |
## 3. Backend deployment
|
| 45 |
|
| 46 |
+
Upload the seven changed backend files listed above to the repository root. Allow the six replacement files to overwrite their existing versions and add `RELEASE_1.8.1.md`.
|
| 47 |
|
| 48 |
After the Space rebuilds, check:
|
| 49 |
|
|
|
|
| 53 |
|
| 54 |
Expected indicators:
|
| 55 |
|
| 56 |
+
- `version: 1.8.1`
|
| 57 |
- journal index loaded with an active-record count
|
| 58 |
- Open Policy Finder configured when its secret is present
|
| 59 |
- knowledge base loaded
|
|
|
|
| 190 |
6. Use **Review or correct inferred details**, amend a field, and resubmit.
|
| 191 |
7. Compare and export two to five journals.
|
| 192 |
|
| 193 |
+
### Subject spelling and taxonomy fallback
|
| 194 |
+
|
| 195 |
+
1. Search `artifical inteligence` and confirm the interface shows the correction to **Artificial Intelligence** before returning verified journals.
|
| 196 |
+
2. Search a meaningful unlisted subject such as `criminology`; confirm the interface states that no exact taxonomy category exists, names the nearest verified category used, and offers alternatives.
|
| 197 |
+
3. Search meaningless input such as `asdfgh`; confirm ResearchBee does not force a category and instead returns verified suggestions or a safe no-match message.
|
| 198 |
+
4. Confirm every returned category exists in the bundled SCImago/ASJC vocabulary.
|
| 199 |
+
|
| 200 |
### Safety and regression
|
| 201 |
|
| 202 |
1. Disconnect or remove the OPF key in a test environment. Policy results must remain unavailable/unknown rather than guessed.
|
| 203 |
2. Temporarily make the journal database unavailable. Both journal routes must return a safe error rather than model-created titles.
|
| 204 |
3. Confirm the DOI-based licence workflow still uses OA.Works and Khazna independently.
|
| 205 |
4. Confirm repository finder, chat, service introduction, language controls, mobile tabs, cover-letter generation, and related works still operate.
|
| 206 |
+
5. Confirm `/api/health` reports `1.8.1`.
|
| 207 |
|
| 208 |
## 9. Known evidence limitations
|
| 209 |
|
README.md
CHANGED
|
@@ -10,7 +10,7 @@ license: mit
|
|
| 10 |
short_description: Journal selection, Open Access and research data assistant
|
| 11 |
---
|
| 12 |
|
| 13 |
-
# 🐝 ResearchBee v1.8.
|
| 14 |
|
| 15 |
**Publishing, Open Access, and research-data assistant for Khalifa University researchers**
|
| 16 |
|
|
@@ -21,6 +21,21 @@ ResearchBee combines three structured tools with a source-aware chat assistant:
|
|
| 21 |
3. **Deposit your data** — match a dataset to curated repository records and preserve factual repository attributes on the server.
|
| 22 |
4. **Ask ResearchBee** — get guidance on journal selection, metrics, publishing practice, KU Open Access support, research data management, repository selection, and responsible use of AI.
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
## What changed in v1.8.0
|
| 25 |
|
| 26 |
### One journal-discovery system for both modes
|
|
@@ -98,7 +113,7 @@ OPEN_POLICY_FINDER_API_KEY
|
|
| 98 |
|
| 99 |
`OPEN_POLICY_FINDER_API_KEY` is required for journal-level policy checks in both Journal Finder modes and in the separate licence workflow. It must be stored only as a Hugging Face Space secret.
|
| 100 |
|
| 101 |
-
## Main runtime files for v1.8.
|
| 102 |
|
| 103 |
```text
|
| 104 |
app.py
|
|
|
|
| 10 |
short_description: Journal selection, Open Access and research data assistant
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# 🐝 ResearchBee v1.8.1
|
| 14 |
|
| 15 |
**Publishing, Open Access, and research-data assistant for Khalifa University researchers**
|
| 16 |
|
|
|
|
| 21 |
3. **Deposit your data** — match a dataset to curated repository records and preserve factual repository attributes on the server.
|
| 22 |
4. **Ask ResearchBee** — get guidance on journal selection, metrics, publishing practice, KU Open Access support, research data management, repository selection, and responsible use of AI.
|
| 23 |
|
| 24 |
+
## What changed in v1.8.1
|
| 25 |
+
|
| 26 |
+
### Journal Finder display alignment
|
| 27 |
+
|
| 28 |
+
- The LibGuides interface now checks for the `journal_discovery_v2` backend schema and reports a clear version-mismatch message instead of silently falling back to the legacy journal-card renderer.
|
| 29 |
+
- Both **Browse by subject** and **Analyse manuscript** use the same access-model badge, comparison selector, comparison table, CSV export, and on-demand sharing-policy controls.
|
| 30 |
+
- Journal-level OA source flags are labelled separately from manuscript-sharing policy. An unchecked sharing policy no longer makes an open-access journal appear to have an unverified OA status.
|
| 31 |
+
|
| 32 |
+
### Subject spelling and nearest-category mapping
|
| 33 |
+
|
| 34 |
+
- High-confidence spelling mistakes are corrected locally against the closed SCImago/ASJC vocabulary; the original and corrected terms are shown to the user.
|
| 35 |
+
- Meaningful subjects that are absent from the bundled taxonomy may be mapped by the language model to the nearest defensible listed category. The server validates the selected term against the approved vocabulary and explicitly states that a nearest-category mapping was used.
|
| 36 |
+
- Alternative verified categories are displayed as one-click refinements. Meaningless or materially ambiguous input is not forced into a category.
|
| 37 |
+
- Deterministic suggestions now weight distinctive terms more strongly than generic words such as “science”.
|
| 38 |
+
|
| 39 |
## What changed in v1.8.0
|
| 40 |
|
| 41 |
### One journal-discovery system for both modes
|
|
|
|
| 113 |
|
| 114 |
`OPEN_POLICY_FINDER_API_KEY` is required for journal-level policy checks in both Journal Finder modes and in the separate licence workflow. It must be stored only as a Hugging Face Space secret.
|
| 115 |
|
| 116 |
+
## Main runtime files for v1.8.1
|
| 117 |
|
| 118 |
```text
|
| 119 |
app.py
|
RELEASE_1.8.1.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ResearchBee v1.8.1 — Journal Finder display and subject-mapping patch
|
| 2 |
+
|
| 3 |
+
Released: 2026-09-09
|
| 4 |
+
|
| 5 |
+
## Summary
|
| 6 |
+
|
| 7 |
+
ResearchBee v1.8.1 fixes a front-end/back-end alignment problem that could make **Analyse manuscript** use the legacy result renderer. In that state, the new top-level journal access data was ignored, every card could show an old “not verified” policy status, and the unified comparison controls were absent. The patch also makes subject spelling correction and nearest-category fallback explicit and testable.
|
| 8 |
+
|
| 9 |
+
## Changes
|
| 10 |
+
|
| 11 |
+
### Both Journal Finder modes
|
| 12 |
+
|
| 13 |
+
- **Browse by subject** and **Analyse manuscript** now enforce the same `journal_discovery_v2` display path.
|
| 14 |
+
- Both modes expose journal selection, a two-to-five-journal comparison table, CSV export, access-model source flags, and on-demand sharing-policy checks.
|
| 15 |
+
- The interface detects a mismatched backend schema and asks for a refresh after deployment rather than silently displaying legacy cards.
|
| 16 |
+
- Journal access model and manuscript-sharing policy remain separate: an unchecked Open Policy Finder policy does not mean that the journal’s OA status is unverified.
|
| 17 |
+
|
| 18 |
+
### Subject input
|
| 19 |
+
|
| 20 |
+
- Added conservative high-confidence spelling correction against the approved local category and area vocabulary.
|
| 21 |
+
- Added a constrained LLM nearest-category route for meaningful subjects not represented exactly in the taxonomy.
|
| 22 |
+
- The server validates every returned category or area before use.
|
| 23 |
+
- The result states the original term, the verified category/area selected, and whether the selection was a spelling correction, synonym, broad-area mapping, or nearest-category fallback.
|
| 24 |
+
- Alternative verified categories are provided as one-click refinements.
|
| 25 |
+
- Nonsense or unsafe ambiguity remains a no-match; ResearchBee does not force a category.
|
| 26 |
+
|
| 27 |
+
## Files to upload to Hugging Face
|
| 28 |
+
|
| 29 |
+
Add or replace only:
|
| 30 |
+
|
| 31 |
+
```text
|
| 32 |
+
app.py
|
| 33 |
+
scimago_index.py
|
| 34 |
+
prompts.py
|
| 35 |
+
openpolicyfinder.py
|
| 36 |
+
README.md
|
| 37 |
+
README-DEPLOY.md
|
| 38 |
+
RELEASE_1.8.1.md
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
Do not delete or re-upload `journals.db`, `scimagojr_2025.csv`, or other unchanged runtime files.
|
| 42 |
+
|
| 43 |
+
## LibGuides
|
| 44 |
+
|
| 45 |
+
Replace the complete existing ResearchBee source fragment with `ResearchBee_LibGuides_v1.8.1.html`. Do not upload the HTML file to the Hugging Face Space.
|
| 46 |
+
|
| 47 |
+
After publishing the LibGuide, hard-refresh the page or open it in a private window. The footer should show **Interface v1.8.1**.
|
| 48 |
+
|
| 49 |
+
## Verification
|
| 50 |
+
|
| 51 |
+
- 49 automated tests passed.
|
| 52 |
+
- Python source compilation passed.
|
| 53 |
+
- Embedded LibGuides JavaScript syntax validation passed.
|
| 54 |
+
- Browser interaction tests covered manuscript OA display, manuscript comparison, subject mapping disclosure, and alternative-category refinement.
|
app.py
CHANGED
|
@@ -1,5 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
-
ResearchBee - FastAPI backend v1.8.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
Changes in v1.8.0:
|
| 5 |
- Browse by subject and manuscript matching now share one database-first journal pipeline.
|
|
@@ -77,7 +83,7 @@ import httpx
|
|
| 77 |
from journal_lookup import (
|
| 78 |
lookup_any, load_scimago, search_by_subject, load_subject_index,
|
| 79 |
subject_vocabulary, is_real_category, is_real_area, suggest_categories,
|
| 80 |
-
get_index,
|
| 81 |
)
|
| 82 |
import openalex
|
| 83 |
import openpolicyfinder
|
|
@@ -446,7 +452,7 @@ async def lifespan(app: FastAPI):
|
|
| 446 |
|
| 447 |
|
| 448 |
# ── App ────────────────────────────────────────────────────────────────────
|
| 449 |
-
app = FastAPI(title="ResearchBee API", version="1.8.
|
| 450 |
|
| 451 |
app.add_middleware(
|
| 452 |
CORSMiddleware,
|
|
@@ -1014,7 +1020,7 @@ async def health():
|
|
| 1014 |
return {
|
| 1015 |
"status": "ok",
|
| 1016 |
"service": "ResearchBee",
|
| 1017 |
-
"version": "1.8.
|
| 1018 |
# Restart detection for the uptime monitor. boot_ts is the stable
|
| 1019 |
# identity of this process; when it changes between two polls the
|
| 1020 |
# container was replaced, whatever the HTTP status said.
|
|
@@ -2353,9 +2359,11 @@ async def browse_subject(req: SubjectRequest, request: Request):
|
|
| 2353 |
subject_text = req.subject.strip()
|
| 2354 |
typed_category = subject_text if is_real_category(subject_text) else ""
|
| 2355 |
typed_area = subject_text if is_real_area(subject_text) else ""
|
|
|
|
| 2356 |
|
| 2357 |
-
#
|
| 2358 |
-
#
|
|
|
|
| 2359 |
if typed_category or typed_area:
|
| 2360 |
parsed = {
|
| 2361 |
"confidence": "exact",
|
|
@@ -2363,15 +2371,33 @@ async def browse_subject(req: SubjectRequest, request: Request):
|
|
| 2363 |
"scimago_category": typed_category,
|
| 2364 |
"scimago_area": typed_area,
|
| 2365 |
"nearest_categories": [],
|
|
|
|
|
|
|
| 2366 |
"user_message": "Matched directly to the verified ASJC/SCImago vocabulary.",
|
| 2367 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2368 |
else:
|
| 2369 |
try:
|
| 2370 |
parsed = await call_openai(
|
| 2371 |
get_client(),
|
| 2372 |
subject_prompt_with_vocabulary(),
|
| 2373 |
f"{lang_instruction(req.language)}Subject input: {subject_text}",
|
| 2374 |
-
max_tokens=
|
| 2375 |
model=MODEL_LIGHT,
|
| 2376 |
)
|
| 2377 |
except (RateLimitError, APIStatusError, HTTPException) as exc:
|
|
@@ -2382,6 +2408,8 @@ async def browse_subject(req: SubjectRequest, request: Request):
|
|
| 2382 |
"scimago_category": "",
|
| 2383 |
"scimago_area": "",
|
| 2384 |
"nearest_categories": [],
|
|
|
|
|
|
|
| 2385 |
"user_message": "The subject classifier was unavailable. Choose a verified category suggestion or try again.",
|
| 2386 |
}
|
| 2387 |
except Exception as exc:
|
|
@@ -2392,15 +2420,32 @@ async def browse_subject(req: SubjectRequest, request: Request):
|
|
| 2392 |
"scimago_category": "",
|
| 2393 |
"scimago_area": "",
|
| 2394 |
"nearest_categories": [],
|
|
|
|
|
|
|
| 2395 |
"user_message": "The subject could not be classified safely. Choose a verified category suggestion or try again.",
|
| 2396 |
}
|
| 2397 |
|
| 2398 |
confidence = parsed.get("confidence", "not_found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2399 |
normalised = str(parsed.get("normalised") or subject_text).strip()
|
| 2400 |
category = str(parsed.get("scimago_category") or "").strip()
|
| 2401 |
area = str(parsed.get("scimago_area") or "").strip()
|
| 2402 |
user_message = " ".join(str(parsed.get("user_message") or "").split())
|
| 2403 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2404 |
bad_category = bool(category) and not is_real_category(category)
|
| 2405 |
bad_area = bool(area) and not is_real_area(area)
|
| 2406 |
if bad_category:
|
|
@@ -2410,28 +2455,81 @@ async def browse_subject(req: SubjectRequest, request: Request):
|
|
| 2410 |
logger.warning("[BrowseSubject] discarded unlisted area %r", area)
|
| 2411 |
area = ""
|
| 2412 |
|
| 2413 |
-
# Exact typed terms always win over a model's alternative valid
|
| 2414 |
if typed_category:
|
| 2415 |
category = typed_category
|
|
|
|
| 2416 |
confidence = "exact"
|
|
|
|
| 2417 |
if typed_area:
|
| 2418 |
area = typed_area
|
|
|
|
| 2419 |
confidence = "exact"
|
| 2420 |
-
|
| 2421 |
-
|
| 2422 |
-
|
| 2423 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2424 |
key = str(name).strip().lower()
|
| 2425 |
if key and key not in seen and is_real_category(key):
|
| 2426 |
-
seen.add(key)
|
| 2427 |
-
|
| 2428 |
-
|
| 2429 |
-
|
|
|
|
|
|
|
|
|
|
| 2430 |
counts = subject_vocabulary()["categories"]
|
| 2431 |
return [{"category": name.title(), "n_journals": counts.get(name, 0)} for name in out[:6]]
|
| 2432 |
|
| 2433 |
have_category = bool(category)
|
| 2434 |
-
|
|
|
|
|
|
|
| 2435 |
if confidence == "not_found" or (not have_category and not have_area):
|
| 2436 |
usage.usage.record_gap(
|
| 2437 |
"journal_subject",
|
|
@@ -2439,9 +2537,9 @@ async def browse_subject(req: SubjectRequest, request: Request):
|
|
| 2439 |
)
|
| 2440 |
request.state.usage_recorded = True
|
| 2441 |
nearby = suggestions()
|
| 2442 |
-
base = f"SCImago and Scopus have no exact subject category for '{
|
| 2443 |
hint = (
|
| 2444 |
-
" Closest
|
| 2445 |
if nearby else ""
|
| 2446 |
)
|
| 2447 |
return {"result": {
|
|
@@ -2450,6 +2548,16 @@ async def browse_subject(req: SubjectRequest, request: Request):
|
|
| 2450 |
"confidence": "not_found",
|
| 2451 |
"normalised": normalised,
|
| 2452 |
"user_message": (user_message + " " if user_message else "") + base + hint,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2453 |
"suggestions": nearby,
|
| 2454 |
"journals": [],
|
| 2455 |
"extended_list": [],
|
|
@@ -2500,13 +2608,18 @@ async def browse_subject(req: SubjectRequest, request: Request):
|
|
| 2500 |
unverified.extend(policy_notes)
|
| 2501 |
if have_area and not have_category:
|
| 2502 |
area_note = (
|
| 2503 |
-
f"'{
|
| 2504 |
-
f"'{area.title()}'; choose a narrower category for a more specific list."
|
| 2505 |
)
|
| 2506 |
user_message = f"{user_message} {area_note}".strip()
|
| 2507 |
usage.usage.record_gap("journal_subject", "area_fallback")
|
| 2508 |
request.state.usage_recorded = True
|
| 2509 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2510 |
result = _finalise_discovery_result(
|
| 2511 |
mode="subject",
|
| 2512 |
ranked=ranked,
|
|
@@ -2521,6 +2634,9 @@ async def browse_subject(req: SubjectRequest, request: Request):
|
|
| 2521 |
"category": category,
|
| 2522 |
"area": area,
|
| 2523 |
"confidence": confidence,
|
|
|
|
|
|
|
|
|
|
| 2524 |
},
|
| 2525 |
classification_note=(
|
| 2526 |
"This is a broad area result, not a manuscript-specific fit assessment."
|
|
@@ -2535,7 +2651,17 @@ async def browse_subject(req: SubjectRequest, request: Request):
|
|
| 2535 |
"scimago_area": area,
|
| 2536 |
"user_message": user_message,
|
| 2537 |
"asjc_codes": asjc_codes,
|
| 2538 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2539 |
})
|
| 2540 |
if not ranked:
|
| 2541 |
result["filter_no_match"] = True
|
|
|
|
| 1 |
"""
|
| 2 |
+
ResearchBee - FastAPI backend v1.8.1 (journal UI alignment and subject correction)
|
| 3 |
+
|
| 4 |
+
Changes in v1.8.1:
|
| 5 |
+
- Subject browsing applies conservative closed-vocabulary spelling correction before the LLM.
|
| 6 |
+
- Meaningful unlisted subjects may be mapped to the nearest verified category with explicit disclosure.
|
| 7 |
+
- Subject responses include structured mapping provenance and alternative verified categories.
|
| 8 |
+
- The backend version aligns with the unified comparison/OA front end.
|
| 9 |
|
| 10 |
Changes in v1.8.0:
|
| 11 |
- Browse by subject and manuscript matching now share one database-first journal pipeline.
|
|
|
|
| 83 |
from journal_lookup import (
|
| 84 |
lookup_any, load_scimago, search_by_subject, load_subject_index,
|
| 85 |
subject_vocabulary, is_real_category, is_real_area, suggest_categories,
|
| 86 |
+
closest_subject_term, get_index,
|
| 87 |
)
|
| 88 |
import openalex
|
| 89 |
import openpolicyfinder
|
|
|
|
| 452 |
|
| 453 |
|
| 454 |
# ── App ────────────────────────────────────────────────────────────────────
|
| 455 |
+
app = FastAPI(title="ResearchBee API", version="1.8.1", lifespan=lifespan)
|
| 456 |
|
| 457 |
app.add_middleware(
|
| 458 |
CORSMiddleware,
|
|
|
|
| 1020 |
return {
|
| 1021 |
"status": "ok",
|
| 1022 |
"service": "ResearchBee",
|
| 1023 |
+
"version": "1.8.1",
|
| 1024 |
# Restart detection for the uptime monitor. boot_ts is the stable
|
| 1025 |
# identity of this process; when it changes between two polls the
|
| 1026 |
# container was replaced, whatever the HTTP status said.
|
|
|
|
| 2359 |
subject_text = req.subject.strip()
|
| 2360 |
typed_category = subject_text if is_real_category(subject_text) else ""
|
| 2361 |
typed_area = subject_text if is_real_area(subject_text) else ""
|
| 2362 |
+
spelling_match = None if (typed_category or typed_area) else closest_subject_term(subject_text)
|
| 2363 |
|
| 2364 |
+
# Exact vocabulary terms and conservative spelling repairs are resolved
|
| 2365 |
+
# locally. The LLM is reserved for synonym and semantic nearest-category
|
| 2366 |
+
# mapping, and may only return names from the closed vocabulary.
|
| 2367 |
if typed_category or typed_area:
|
| 2368 |
parsed = {
|
| 2369 |
"confidence": "exact",
|
|
|
|
| 2371 |
"scimago_category": typed_category,
|
| 2372 |
"scimago_area": typed_area,
|
| 2373 |
"nearest_categories": [],
|
| 2374 |
+
"mapping_type": "exact",
|
| 2375 |
+
"use_nearest_category": False,
|
| 2376 |
"user_message": "Matched directly to the verified ASJC/SCImago vocabulary.",
|
| 2377 |
}
|
| 2378 |
+
elif spelling_match:
|
| 2379 |
+
corrected = str(spelling_match["name"])
|
| 2380 |
+
is_category = spelling_match["kind"] == "category"
|
| 2381 |
+
parsed = {
|
| 2382 |
+
"confidence": "near",
|
| 2383 |
+
"normalised": corrected,
|
| 2384 |
+
"scimago_category": corrected if is_category else "",
|
| 2385 |
+
"scimago_area": corrected if not is_category else "",
|
| 2386 |
+
"nearest_categories": suggest_categories(corrected, limit=4),
|
| 2387 |
+
"mapping_type": "spelling_correction",
|
| 2388 |
+
"use_nearest_category": True,
|
| 2389 |
+
"user_message": (
|
| 2390 |
+
f'Spelling corrected: "{subject_text}" → "{corrected.title()}". '
|
| 2391 |
+
f'Results use this verified {spelling_match["kind"]}.'
|
| 2392 |
+
),
|
| 2393 |
+
}
|
| 2394 |
else:
|
| 2395 |
try:
|
| 2396 |
parsed = await call_openai(
|
| 2397 |
get_client(),
|
| 2398 |
subject_prompt_with_vocabulary(),
|
| 2399 |
f"{lang_instruction(req.language)}Subject input: {subject_text}",
|
| 2400 |
+
max_tokens=520,
|
| 2401 |
model=MODEL_LIGHT,
|
| 2402 |
)
|
| 2403 |
except (RateLimitError, APIStatusError, HTTPException) as exc:
|
|
|
|
| 2408 |
"scimago_category": "",
|
| 2409 |
"scimago_area": "",
|
| 2410 |
"nearest_categories": [],
|
| 2411 |
+
"mapping_type": "none",
|
| 2412 |
+
"use_nearest_category": False,
|
| 2413 |
"user_message": "The subject classifier was unavailable. Choose a verified category suggestion or try again.",
|
| 2414 |
}
|
| 2415 |
except Exception as exc:
|
|
|
|
| 2420 |
"scimago_category": "",
|
| 2421 |
"scimago_area": "",
|
| 2422 |
"nearest_categories": [],
|
| 2423 |
+
"mapping_type": "none",
|
| 2424 |
+
"use_nearest_category": False,
|
| 2425 |
"user_message": "The subject could not be classified safely. Choose a verified category suggestion or try again.",
|
| 2426 |
}
|
| 2427 |
|
| 2428 |
confidence = parsed.get("confidence", "not_found")
|
| 2429 |
+
if confidence not in {"exact", "near", "not_found"}:
|
| 2430 |
+
confidence = "not_found"
|
| 2431 |
+
mapping_type = str(parsed.get("mapping_type") or "").strip().lower()
|
| 2432 |
+
if mapping_type not in {"exact", "synonym", "spelling_correction", "nearest_category", "none"}:
|
| 2433 |
+
mapping_type = "nearest_category" if confidence == "near" else "none"
|
| 2434 |
+
use_nearest_category = parsed.get("use_nearest_category") is True
|
| 2435 |
normalised = str(parsed.get("normalised") or subject_text).strip()
|
| 2436 |
category = str(parsed.get("scimago_category") or "").strip()
|
| 2437 |
area = str(parsed.get("scimago_area") or "").strip()
|
| 2438 |
user_message = " ".join(str(parsed.get("user_message") or "").split())
|
| 2439 |
|
| 2440 |
+
nearest_categories: list[str] = []
|
| 2441 |
+
nearest_seen: set[str] = set()
|
| 2442 |
+
for name in parsed.get("nearest_categories") or []:
|
| 2443 |
+
value = str(name).strip()
|
| 2444 |
+
key = value.lower()
|
| 2445 |
+
if key and key not in nearest_seen and is_real_category(value):
|
| 2446 |
+
nearest_seen.add(key)
|
| 2447 |
+
nearest_categories.append(value)
|
| 2448 |
+
|
| 2449 |
bad_category = bool(category) and not is_real_category(category)
|
| 2450 |
bad_area = bool(area) and not is_real_area(area)
|
| 2451 |
if bad_category:
|
|
|
|
| 2455 |
logger.warning("[BrowseSubject] discarded unlisted area %r", area)
|
| 2456 |
area = ""
|
| 2457 |
|
| 2458 |
+
# Exact typed terms always win over a model's alternative valid term.
|
| 2459 |
if typed_category:
|
| 2460 |
category = typed_category
|
| 2461 |
+
area = ""
|
| 2462 |
confidence = "exact"
|
| 2463 |
+
mapping_type = "exact"
|
| 2464 |
if typed_area:
|
| 2465 |
area = typed_area
|
| 2466 |
+
category = ""
|
| 2467 |
confidence = "exact"
|
| 2468 |
+
mapping_type = "exact"
|
| 2469 |
+
|
| 2470 |
+
# A model may conservatively return the chosen nearest category only in
|
| 2471 |
+
# nearest_categories. Use it only when the model explicitly says the
|
| 2472 |
+
# fallback is safe, or when it labelled the response "near". A true
|
| 2473 |
+
# not_found with use_nearest_category=false remains a no-result response.
|
| 2474 |
+
used_nearest_fallback = False
|
| 2475 |
+
if not category and not area and nearest_categories and (
|
| 2476 |
+
use_nearest_category or confidence == "near"
|
| 2477 |
+
):
|
| 2478 |
+
category = nearest_categories[0]
|
| 2479 |
+
confidence = "near"
|
| 2480 |
+
mapping_type = "nearest_category"
|
| 2481 |
+
used_nearest_fallback = True
|
| 2482 |
+
if not normalised or normalised == subject_text:
|
| 2483 |
+
normalised = category
|
| 2484 |
+
|
| 2485 |
+
selected_term = category or area
|
| 2486 |
+
if selected_term:
|
| 2487 |
+
display_term = selected_term.title()
|
| 2488 |
+
if mapping_type == "spelling_correction":
|
| 2489 |
+
disclosure = (
|
| 2490 |
+
f'Spelling corrected: "{subject_text}" → "{display_term}". '
|
| 2491 |
+
f'Results use this verified {"category" if category else "broad area"}.'
|
| 2492 |
+
)
|
| 2493 |
+
elif mapping_type == "nearest_category" or used_nearest_fallback:
|
| 2494 |
+
disclosure = (
|
| 2495 |
+
f'SCImago and Scopus have no exact subject category for "{subject_text}". '
|
| 2496 |
+
f'ResearchBee mapped it to the nearest verified category "{display_term}".'
|
| 2497 |
+
)
|
| 2498 |
+
elif mapping_type == "synonym" and selected_term.lower() != subject_text.lower():
|
| 2499 |
+
disclosure = (
|
| 2500 |
+
f'Interpreted "{subject_text}" as the verified '
|
| 2501 |
+
f'{"category" if category else "area"} "{display_term}".'
|
| 2502 |
+
)
|
| 2503 |
+
elif confidence == "near" and selected_term.lower() != subject_text.lower():
|
| 2504 |
+
disclosure = (
|
| 2505 |
+
f'Interpreted "{subject_text}" using the closest verified '
|
| 2506 |
+
f'{"category" if category else "area"} "{display_term}".'
|
| 2507 |
+
)
|
| 2508 |
+
else:
|
| 2509 |
+
disclosure = ""
|
| 2510 |
+
if disclosure and disclosure.lower() not in user_message.lower():
|
| 2511 |
+
user_message = f"{disclosure} {user_message}".strip()
|
| 2512 |
+
|
| 2513 |
+
def suggestions() -> list[dict]:
|
| 2514 |
+
out: list[str] = []
|
| 2515 |
+
seen: set[str] = set()
|
| 2516 |
+
for name in nearest_categories:
|
| 2517 |
key = str(name).strip().lower()
|
| 2518 |
if key and key not in seen and is_real_category(key):
|
| 2519 |
+
seen.add(key)
|
| 2520 |
+
out.append(key)
|
| 2521 |
+
for name in suggest_categories(subject_text, limit=8):
|
| 2522 |
+
key = str(name).strip().lower()
|
| 2523 |
+
if key and key not in seen and is_real_category(key):
|
| 2524 |
+
seen.add(key)
|
| 2525 |
+
out.append(key)
|
| 2526 |
counts = subject_vocabulary()["categories"]
|
| 2527 |
return [{"category": name.title(), "n_journals": counts.get(name, 0)} for name in out[:6]]
|
| 2528 |
|
| 2529 |
have_category = bool(category)
|
| 2530 |
+
# Permit a corrected or LLM-resolved broad area when no category is
|
| 2531 |
+
# available. Exact typed areas continue to take precedence.
|
| 2532 |
+
have_area = bool(area) and not have_category
|
| 2533 |
if confidence == "not_found" or (not have_category and not have_area):
|
| 2534 |
usage.usage.record_gap(
|
| 2535 |
"journal_subject",
|
|
|
|
| 2537 |
)
|
| 2538 |
request.state.usage_recorded = True
|
| 2539 |
nearby = suggestions()
|
| 2540 |
+
base = f"SCImago and Scopus have no exact subject category for '{subject_text}'."
|
| 2541 |
hint = (
|
| 2542 |
+
" Closest verified categories: " + ", ".join(item["category"] for item in nearby) + "."
|
| 2543 |
if nearby else ""
|
| 2544 |
)
|
| 2545 |
return {"result": {
|
|
|
|
| 2548 |
"confidence": "not_found",
|
| 2549 |
"normalised": normalised,
|
| 2550 |
"user_message": (user_message + " " if user_message else "") + base + hint,
|
| 2551 |
+
"subject_mapping": {
|
| 2552 |
+
"original_input": subject_text,
|
| 2553 |
+
"normalised": normalised,
|
| 2554 |
+
"match_type": "none",
|
| 2555 |
+
"selected_category": "",
|
| 2556 |
+
"selected_area": "",
|
| 2557 |
+
"used_nearest_category": False,
|
| 2558 |
+
"spelling_corrected": False,
|
| 2559 |
+
"alternatives": nearby,
|
| 2560 |
+
},
|
| 2561 |
"suggestions": nearby,
|
| 2562 |
"journals": [],
|
| 2563 |
"extended_list": [],
|
|
|
|
| 2608 |
unverified.extend(policy_notes)
|
| 2609 |
if have_area and not have_category:
|
| 2610 |
area_note = (
|
| 2611 |
+
f"'{subject_text}' was resolved to the broad subject area "
|
| 2612 |
+
f"'{area.title()}'. Results span that area; choose a narrower category for a more specific list."
|
| 2613 |
)
|
| 2614 |
user_message = f"{user_message} {area_note}".strip()
|
| 2615 |
usage.usage.record_gap("journal_subject", "area_fallback")
|
| 2616 |
request.state.usage_recorded = True
|
| 2617 |
|
| 2618 |
+
all_suggestions = suggestions()
|
| 2619 |
+
alternatives = [
|
| 2620 |
+
item for item in all_suggestions
|
| 2621 |
+
if item["category"].lower() != str(category).lower()
|
| 2622 |
+
][:4]
|
| 2623 |
result = _finalise_discovery_result(
|
| 2624 |
mode="subject",
|
| 2625 |
ranked=ranked,
|
|
|
|
| 2634 |
"category": category,
|
| 2635 |
"area": area,
|
| 2636 |
"confidence": confidence,
|
| 2637 |
+
"mapping_type": mapping_type,
|
| 2638 |
+
"original_input": subject_text,
|
| 2639 |
+
"alternatives": alternatives,
|
| 2640 |
},
|
| 2641 |
classification_note=(
|
| 2642 |
"This is a broad area result, not a manuscript-specific fit assessment."
|
|
|
|
| 2651 |
"scimago_area": area,
|
| 2652 |
"user_message": user_message,
|
| 2653 |
"asjc_codes": asjc_codes,
|
| 2654 |
+
"subject_mapping": {
|
| 2655 |
+
"original_input": subject_text,
|
| 2656 |
+
"normalised": normalised,
|
| 2657 |
+
"match_type": mapping_type,
|
| 2658 |
+
"selected_category": category,
|
| 2659 |
+
"selected_area": area,
|
| 2660 |
+
"used_nearest_category": mapping_type == "nearest_category" or used_nearest_fallback,
|
| 2661 |
+
"spelling_corrected": mapping_type == "spelling_correction",
|
| 2662 |
+
"alternatives": alternatives,
|
| 2663 |
+
},
|
| 2664 |
+
"suggestions": alternatives if confidence == "near" else [],
|
| 2665 |
})
|
| 2666 |
if not ranked:
|
| 2667 |
result["filter_no_match"] = True
|
openpolicyfinder.py
CHANGED
|
@@ -55,7 +55,7 @@ LICENCE_NAME = "CC BY-NC-ND 4.0"
|
|
| 55 |
LICENCE_URL = "https://creativecommons.org/licenses/by-nc-nd/4.0/"
|
| 56 |
CONTACT = "nikesh.narayanan@ku.ac.ae"
|
| 57 |
USER_AGENT = (
|
| 58 |
-
"ResearchBee/1.8.
|
| 59 |
f"mailto:{CONTACT}; Open Policy Finder API client)"
|
| 60 |
)
|
| 61 |
|
|
|
|
| 55 |
LICENCE_URL = "https://creativecommons.org/licenses/by-nc-nd/4.0/"
|
| 56 |
CONTACT = "nikesh.narayanan@ku.ac.ae"
|
| 57 |
USER_AGENT = (
|
| 58 |
+
"ResearchBee/1.8.1 (Khalifa University Library; "
|
| 59 |
f"mailto:{CONTACT}; Open Policy Finder API client)"
|
| 60 |
)
|
| 61 |
|
prompts.py
CHANGED
|
@@ -265,41 +265,58 @@ Rules:
|
|
| 265 |
"""
|
| 266 |
|
| 267 |
# ── Subject normalisation ─────────────────────────────────────────────────
|
| 268 |
-
SUBJECT_NORMALISE_PROMPT = """You are an academic subject classification expert. Map the user's subject
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
| 276 |
|
| 277 |
Return ONLY valid JSON:
|
| 278 |
|
| 279 |
{
|
| 280 |
"confidence": "exact|near|not_found",
|
| 281 |
-
"normalised": "
|
| 282 |
-
"scimago_category": "A name copied verbatim from VALID CATEGORIES, or
|
| 283 |
-
"scimago_area": "A name copied verbatim from VALID AREAS, or
|
| 284 |
"nearest_categories": ["Up to 4 names copied verbatim from VALID CATEGORIES"],
|
| 285 |
-
"
|
|
|
|
|
|
|
| 286 |
}
|
| 287 |
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
-
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
# ── Cover letter ──────────────────────────────────────────────────────────
|
| 305 |
COVER_LETTER_PROMPT = """You are ResearchBee, an expert academic writing assistant. Generate a professional cover letter for journal submission. Return ONLY valid JSON:
|
|
|
|
| 265 |
"""
|
| 266 |
|
| 267 |
# ── Subject normalisation ─────────────────────────────────────────────────
|
| 268 |
+
SUBJECT_NORMALISE_PROMPT = """You are an academic subject classification expert. Map the user's free-text subject onto the verified SCImago/ASJC vocabulary supplied below.
|
| 269 |
+
|
| 270 |
+
Closed-vocabulary rules:
|
| 271 |
+
- Copy scimago_category character for character from VALID CATEGORIES, or use "".
|
| 272 |
+
- Copy scimago_area character for character from VALID AREAS, or use "".
|
| 273 |
+
- Never invent, translate, pluralise, or reword a category or area name.
|
| 274 |
+
- A meaningful academic subject that has no exact category should normally be
|
| 275 |
+
mapped to the single closest defensible listed category. Mark it as "near"
|
| 276 |
+
and explicitly disclose that the exact subject is absent from the taxonomy.
|
| 277 |
+
- Use "not_found" only when the input is meaningless, is not an academic
|
| 278 |
+
subject, or is too ambiguous to map responsibly. Do not force a match.
|
| 279 |
|
| 280 |
Return ONLY valid JSON:
|
| 281 |
|
| 282 |
{
|
| 283 |
"confidence": "exact|near|not_found",
|
| 284 |
+
"normalised": "Corrected or normalised user subject",
|
| 285 |
+
"scimago_category": "A name copied verbatim from VALID CATEGORIES, or empty",
|
| 286 |
+
"scimago_area": "A name copied verbatim from VALID AREAS, or empty",
|
| 287 |
"nearest_categories": ["Up to 4 names copied verbatim from VALID CATEGORIES"],
|
| 288 |
+
"mapping_type": "exact|synonym|spelling_correction|nearest_category|none",
|
| 289 |
+
"use_nearest_category": true,
|
| 290 |
+
"user_message": "A short transparent explanation for the researcher"
|
| 291 |
}
|
| 292 |
|
| 293 |
+
Decision rules:
|
| 294 |
+
- Exact listed category or area: confidence "exact", mapping_type "exact".
|
| 295 |
+
- Unambiguous common synonym or abbreviation: choose the listed term,
|
| 296 |
+
confidence "exact" or "near", mapping_type "synonym", and say what was used.
|
| 297 |
+
- Misspelling: correct it, choose the corrected listed category or area,
|
| 298 |
+
confidence "near", mapping_type "spelling_correction", and show
|
| 299 |
+
“original -> corrected” in user_message.
|
| 300 |
+
- Meaningful subject with no exact listed category: choose the closest listed
|
| 301 |
+
category in scimago_category, confidence "near", mapping_type
|
| 302 |
+
"nearest_category", use_nearest_category true, and say clearly that there is
|
| 303 |
+
no exact SCImago/Scopus category and which nearest category is being used.
|
| 304 |
+
- Truly unusable input: leave category and area empty, confidence "not_found",
|
| 305 |
+
mapping_type "none", use_nearest_category false, and explain why.
|
| 306 |
+
- nearest_categories is required for every "near" or "not_found" response and
|
| 307 |
+
must be ordered from most to least relevant. Include the selected category
|
| 308 |
+
first when confidence is "near".
|
| 309 |
+
- For a broad real area such as Engineering or Computer Science, scimago_area
|
| 310 |
+
may be used without a category, but the message must say results are broad.
|
| 311 |
+
|
| 312 |
+
Examples of semantic nearest mapping:
|
| 313 |
+
- “criminology” may map to “Sociology and Political Science” with alternatives
|
| 314 |
+
such as “Law” and “Pathology and Forensic Medicine”.
|
| 315 |
+
- “robotics” may map to the closest defensible engineering or AI category based
|
| 316 |
+
on the wording, while disclosing that Robotics is not an exact listed category.
|
| 317 |
+
|
| 318 |
+
A plausible but unlisted category is worse than an honest nearest-category
|
| 319 |
+
mapping. Every returned category and area is validated by the server."""
|
| 320 |
|
| 321 |
# ── Cover letter ──────────────────────────────────────────────────────────
|
| 322 |
COVER_LETTER_PROMPT = """You are ResearchBee, an expert academic writing assistant. Generate a professional cover letter for journal submission. Return ONLY valid JSON:
|
scimago_index.py
CHANGED
|
@@ -10,7 +10,9 @@ import csv
|
|
| 10 |
import re
|
| 11 |
import os
|
| 12 |
import difflib
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
from utils import norm_issn
|
| 15 |
|
| 16 |
_scimago_cache: dict = {}
|
|
@@ -91,14 +93,98 @@ def is_real_area(name: str) -> bool:
|
|
| 91 |
return bool(name) and name.strip().lower() in subject_vocabulary()["areas"]
|
| 92 |
|
| 93 |
|
| 94 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
"""
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
"""
|
| 103 |
vocab = list(subject_vocabulary()["categories"].keys())
|
| 104 |
q = (text or "").strip().lower()
|
|
@@ -107,27 +193,45 @@ def suggest_categories(text: str, limit: int = 12) -> List[str]:
|
|
| 107 |
if q in vocab:
|
| 108 |
return [q]
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
qt = _tokens(q)
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
for name in vocab:
|
| 113 |
nt = _tokens(name)
|
| 114 |
-
|
|
|
|
| 115 |
continue
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
if n not in out:
|
| 130 |
-
out.append(n)
|
| 131 |
return out[:limit]
|
| 132 |
|
| 133 |
|
|
|
|
| 10 |
import re
|
| 11 |
import os
|
| 12 |
import difflib
|
| 13 |
+
import math
|
| 14 |
+
from collections import Counter
|
| 15 |
+
from typing import Optional, List, Dict, Set, Tuple, Any
|
| 16 |
from utils import norm_issn
|
| 17 |
|
| 18 |
_scimago_cache: dict = {}
|
|
|
|
| 93 |
return bool(name) and name.strip().lower() in subject_vocabulary()["areas"]
|
| 94 |
|
| 95 |
|
| 96 |
+
def _normalise_subject_match_text(value: str, *, strip_qualifier: bool = False) -> str:
|
| 97 |
+
"""Normalise a subject phrase for conservative spelling comparison."""
|
| 98 |
+
text = str(value or "").lower()
|
| 99 |
+
if strip_qualifier:
|
| 100 |
+
text = re.sub(r"\([^)]*\)", " ", text)
|
| 101 |
+
return " ".join(re.findall(r"[a-z0-9]+", text))
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _subject_similarity(query: str, candidate: str) -> Tuple[float, float]:
|
| 105 |
+
"""Return (score, raw_score), lightly discounting qualifier-stripped matches."""
|
| 106 |
+
q_raw = _normalise_subject_match_text(query)
|
| 107 |
+
c_raw = _normalise_subject_match_text(candidate)
|
| 108 |
+
if not q_raw or not c_raw:
|
| 109 |
+
return 0.0, 0.0
|
| 110 |
+
raw = difflib.SequenceMatcher(None, q_raw, c_raw).ratio()
|
| 111 |
+
q_base = _normalise_subject_match_text(query, strip_qualifier=True)
|
| 112 |
+
c_base = _normalise_subject_match_text(candidate, strip_qualifier=True)
|
| 113 |
+
base = difflib.SequenceMatcher(None, q_base, c_base).ratio() if q_base and c_base else 0.0
|
| 114 |
+
return max(raw, base * 0.985), raw
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def closest_subject_term(text: str, cutoff: float = 0.90) -> Optional[Dict[str, Any]]:
|
| 118 |
+
"""Return a high-confidence spelling repair from the closed vocabulary.
|
| 119 |
+
|
| 120 |
+
This is deliberately conservative. It is for typographical repair only,
|
| 121 |
+
not semantic guessing: ``criminology`` must not become ``immunology`` just
|
| 122 |
+
because the strings happen to look similar. Semantic nearest-category
|
| 123 |
+
mapping remains the LLM's job and is constrained to the same vocabulary.
|
| 124 |
"""
|
| 125 |
+
q = str(text or "").strip()
|
| 126 |
+
if len(_normalise_subject_match_text(q)) < 4:
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
ranked: List[Tuple[float, float, str, str]] = []
|
| 130 |
+
vocab = subject_vocabulary()
|
| 131 |
+
for kind in ("categories", "areas"):
|
| 132 |
+
singular = "category" if kind == "categories" else "area"
|
| 133 |
+
for name in vocab[kind]:
|
| 134 |
+
score, raw = _subject_similarity(q, name)
|
| 135 |
+
if score >= cutoff:
|
| 136 |
+
ranked.append((score, raw, singular, name))
|
| 137 |
+
if not ranked:
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
# Prefer the strongest raw phrase match. This makes a misspelt broad area
|
| 141 |
+
# such as "compter scince" resolve to the real area "computer science"
|
| 142 |
+
# instead of the category "computer science (miscellaneous)".
|
| 143 |
+
ranked.sort(key=lambda item: (-item[0], -item[1], item[2] != "area", len(item[3])))
|
| 144 |
+
best = ranked[0]
|
| 145 |
+
if len(ranked) > 1:
|
| 146 |
+
second = ranked[1]
|
| 147 |
+
best_base = _normalise_subject_match_text(best[3], strip_qualifier=True)
|
| 148 |
+
second_base = _normalise_subject_match_text(second[3], strip_qualifier=True)
|
| 149 |
+
# A near-tie between genuinely different terms is not safe enough for
|
| 150 |
+
# an automatic spelling correction. Category/area variants sharing the
|
| 151 |
+
# same base phrase are safe and are resolved by the raw-match rule.
|
| 152 |
+
if best_base != second_base and best[0] - second[0] < 0.025:
|
| 153 |
+
return None
|
| 154 |
+
return {
|
| 155 |
+
"name": best[3],
|
| 156 |
+
"kind": best[2],
|
| 157 |
+
"score": round(best[0], 4),
|
| 158 |
+
"method": "spelling_correction",
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
_subject_token_df_cache: Optional[Dict[str, float]] = None
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _subject_token_weights() -> Dict[str, float]:
|
| 166 |
+
global _subject_token_df_cache
|
| 167 |
+
if _subject_token_df_cache is not None:
|
| 168 |
+
return _subject_token_df_cache
|
| 169 |
+
categories = list(subject_vocabulary()["categories"])
|
| 170 |
+
df: Counter[str] = Counter()
|
| 171 |
+
for name in categories:
|
| 172 |
+
df.update(_tokens(name))
|
| 173 |
+
total = max(len(categories), 1)
|
| 174 |
+
_subject_token_df_cache = {
|
| 175 |
+
token: math.log((total + 1) / (count + 1)) + 1.0
|
| 176 |
+
for token, count in df.items()
|
| 177 |
+
}
|
| 178 |
+
return _subject_token_df_cache
|
| 179 |
|
| 180 |
+
|
| 181 |
+
def suggest_categories(text: str, limit: int = 12) -> List[str]:
|
| 182 |
+
"""Return deterministic, real category suggestions for free text.
|
| 183 |
+
|
| 184 |
+
High-confidence spelling repairs come first. Remaining suggestions are
|
| 185 |
+
ranked by weighted whole-token overlap, so a distinctive term such as
|
| 186 |
+
``sustainability`` outweighs a generic term such as ``science``. The
|
| 187 |
+
function never invents a category and never performs semantic guessing.
|
| 188 |
"""
|
| 189 |
vocab = list(subject_vocabulary()["categories"].keys())
|
| 190 |
q = (text or "").strip().lower()
|
|
|
|
| 193 |
if q in vocab:
|
| 194 |
return [q]
|
| 195 |
|
| 196 |
+
out: List[str] = []
|
| 197 |
+
# Category-only typo candidates. A broad-area spelling correction is
|
| 198 |
+
# handled by ``closest_subject_term`` in the route itself.
|
| 199 |
+
close = []
|
| 200 |
+
for name in vocab:
|
| 201 |
+
score, _raw = _subject_similarity(q, name)
|
| 202 |
+
if score >= 0.90:
|
| 203 |
+
close.append((score, name))
|
| 204 |
+
close.sort(key=lambda item: (-item[0], len(item[1])))
|
| 205 |
+
for _score, name in close:
|
| 206 |
+
if name not in out:
|
| 207 |
+
out.append(name)
|
| 208 |
+
if len(out) >= limit:
|
| 209 |
+
return out
|
| 210 |
+
|
| 211 |
qt = _tokens(q)
|
| 212 |
+
if not qt:
|
| 213 |
+
return out[:limit]
|
| 214 |
+
weights = _subject_token_weights()
|
| 215 |
+
q_total = sum(weights.get(token, 1.0) for token in qt) or 1.0
|
| 216 |
+
ranked: List[Tuple[float, str]] = []
|
| 217 |
for name in vocab:
|
| 218 |
nt = _tokens(name)
|
| 219 |
+
overlap = qt & nt
|
| 220 |
+
if not overlap:
|
| 221 |
continue
|
| 222 |
+
matched = sum(weights.get(token, 1.0) for token in overlap)
|
| 223 |
+
coverage = matched / q_total
|
| 224 |
+
candidate_total = sum(weights.get(token, 1.0) for token in nt) or 1.0
|
| 225 |
+
precision = matched / candidate_total
|
| 226 |
+
char_score, _raw = _subject_similarity(q, name)
|
| 227 |
+
score = 3.0 * coverage + 1.2 * precision + 0.25 * char_score
|
| 228 |
+
ranked.append((score, name))
|
| 229 |
+
ranked.sort(key=lambda item: (-item[0], len(item[1]), item[1]))
|
| 230 |
+
for _score, name in ranked:
|
| 231 |
+
if name not in out:
|
| 232 |
+
out.append(name)
|
| 233 |
+
if len(out) >= limit:
|
| 234 |
+
break
|
|
|
|
|
|
|
| 235 |
return out[:limit]
|
| 236 |
|
| 237 |
|