nikeshn commited on
Commit
1e72bcd
·
verified ·
1 Parent(s): c43c1a5

Upload 7 files

Browse files
Files changed (7) hide show
  1. README-DEPLOY.md +22 -18
  2. README.md +17 -2
  3. RELEASE_1.8.1.md +54 -0
  4. app.py +148 -22
  5. openpolicyfinder.py +1 -1
  6. prompts.py +44 -27
  7. scimago_index.py +128 -24
README-DEPLOY.md CHANGED
@@ -1,25 +1,22 @@
1
- # ResearchBee v1.8.0 — deployment and maintenance guide
2
 
3
- This release replaces the two independent journal-suggestion paths with one verified, database-first journal-discovery system. The same filters, policy checks, scoring, evidence, comparison, and export are available in **Browse by subject** and **Analyse manuscript**.
4
 
5
- ## 1. Files changed or added in v1.8.0
6
 
7
  | File | Action | Purpose |
8
  |---|---|---|
9
- | `app.py` | Replace | Unified journal endpoints, bounded policy batch endpoint, verified result schema |
10
- | `journal_discovery.py` | Add | Shared deterministic candidate generation, filtering, score helpers, policy tri-state |
11
- | `journal_lookup.py` | Replace | Exposes the verified `JournalIndex` instance |
12
- | `researchbee_journals.py` | Replace | Active-record recommendations, exact quartile/access/exclusion filters, preferred-title scoring |
13
- | `openalex.py` | Replace | Recent journal-article evidence and local title-overlap signal |
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.0.md` | Add | Release notes and migration summary |
19
- | `ResearchBee_LibGuides.html` | Replace in LibGuides only | Unified interface for both journal-discovery modes |
20
- | `tests/` | Replace/add for source repository | Regression coverage for both routes and the front end |
21
 
22
- Existing data files such as `journals.db` and `scimagojr_2025.csv` are not altered by this release and should remain in the Space.
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 files marked **Replace** above to overwrite the existing versions, and add the new `journal_discovery.py` and `RELEASE_1.8.0.md` files.
50
 
51
  After the Space rebuilds, check:
52
 
@@ -56,7 +53,7 @@ GET /api/health
56
 
57
  Expected indicators:
58
 
59
- - `version: 1.8.0`
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.0`.
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.0
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.0
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.0 (unified verified journal discovery)
 
 
 
 
 
 
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.0", lifespan=lifespan)
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.0",
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
- # An exact user-entered vocabulary term is stronger evidence than a model
2358
- # interpretation and needs no external classification call.
 
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=450,
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 category.
2414
  if typed_category:
2415
  category = typed_category
 
2416
  confidence = "exact"
 
2417
  if typed_area:
2418
  area = typed_area
 
2419
  confidence = "exact"
2420
-
2421
- def suggestions() -> list:
2422
- out, seen = [], set()
2423
- for name in parsed.get("nearest_categories") or []:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2424
  key = str(name).strip().lower()
2425
  if key and key not in seen and is_real_category(key):
2426
- seen.add(key); out.append(key)
2427
- for name in suggest_categories(req.subject.strip(), limit=6):
2428
- if name not in seen:
2429
- seen.add(name); out.append(name)
 
 
 
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
- have_area = bool(area) and bool(typed_area)
 
 
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 '{req.subject.strip()}'."
2443
  hint = (
2444
- " Closest indexed categories: " + ", ".join(item["category"] for item in nearby) + "."
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"'{req.subject.strip()}' is a broad subject area. Results span the whole of "
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
- "suggestions": suggestions() if confidence == "near" else [],
 
 
 
 
 
 
 
 
 
 
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.0 (Khalifa University Library; "
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 input onto SCImago's classification.
269
-
270
- You MUST choose scimago_category from the VALID CATEGORIES list supplied below,
271
- copied character for character. You MUST choose scimago_area from the VALID
272
- AREAS list. Do not invent, translate, pluralise, or reword a name. If nothing in
273
- the lists fits, that is a legitimate answer — return confidence "not_found" and
274
- say which listed categories come closest. A wrong-but-plausible category name is
275
- far worse than an honest miss: it silently returns an unrelated journal list.
 
 
 
276
 
277
  Return ONLY valid JSON:
278
 
279
  {
280
  "confidence": "exact|near|not_found",
281
- "normalised": "Normalised subject name",
282
- "scimago_category": "A name copied verbatim from VALID CATEGORIES, or \"\" if none fits",
283
- "scimago_area": "A name copied verbatim from VALID AREAS, or \"\" if none fits",
284
  "nearest_categories": ["Up to 4 names copied verbatim from VALID CATEGORIES"],
285
- "user_message": "Optional message if a correction was made or nothing fitted"
 
 
286
  }
287
 
288
- "nearest_categories" is REQUIRED whenever confidence is "near" or "not_found".
289
- It is the fallback the user actually sees, so it does the real work: for an
290
- input with no home in the taxonomy, list the categories that publish closest
291
- to it. For "crime" that is Law; Sociology And Political Science; Pathology And
292
- Forensic Medicine — not an empty list, and not a subject AREA. Copy the names
293
- verbatim; any name not on the list is discarded silently.
294
-
295
- Guidance:
296
- - "exact" the user named a listed category, or an unambiguous synonym of one.
297
- - "near" you mapped to the closest listed category; say so in user_message.
298
- - "not_found" nothing in the list covers it. Name the nearest listed categories
299
- in user_message so the user can pick one.
300
- - If the input is misspelled, give the correct spelling in user_message.
301
- - An area without a category is a weak answer. Only leave scimago_category empty
302
- when no category genuinely fits."""
 
 
 
 
 
 
 
 
 
 
 
 
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
- from typing import Optional, List, Dict, Set, Tuple
 
 
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 suggest_categories(text: str, limit: int = 12) -> List[str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  """
96
- Deterministic shortlist of REAL category names for a free-text subject.
97
- Exact -> whole-word containment -> difflib. Never invents a name.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
- This exists so the LLM chooses from a closed list rather than generating
100
- one: "Crime and Society" is a plausible SCImago category name and is not
101
- a SCImago category at all.
 
 
 
 
 
102
  """
103
  vocab = list(subject_vocabulary()["categories"].keys())
104
  q = (text or "").strip().lower()
@@ -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
- ranked: List[Tuple[int, str]] = []
 
 
 
 
112
  for name in vocab:
113
  nt = _tokens(name)
114
- if not nt:
 
115
  continue
116
- if qt and qt <= nt:
117
- ranked.append((3, name))
118
- elif qt & nt:
119
- ranked.append((2, name))
120
- ranked.sort(key=lambda x: (-x[0], len(x[1])))
121
- out = [n for _, n in ranked][:limit]
122
-
123
- # difflib only as a typo repair, and only at a cutoff high enough to be
124
- # about spelling rather than about vibes. At 0.72 it offered "immunology"
125
- # for "criminology" (ratio 0.76) — lexically close, semantically absurd.
126
- # 0.90 still catches "engineerng" -> "engineering" (0.95).
127
- if len(out) < limit:
128
- for n in difflib.get_close_matches(q, vocab, n=limit - len(out), cutoff=0.90):
129
- if n not in out:
130
- out.append(n)
131
  return out[:limit]
132
 
133
 
 
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