BibexPy 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bibex_core/MergeDB.py +1189 -0
- bibex_core/__init__.py +13 -0
- bibex_core/modules/__init__.py +35 -0
- bibex_core/modules/api_utils.py +1172 -0
- bibex_core/modules/c1_utils.py +423 -0
- bibex_core/modules/file_utils.py +71 -0
- bibex_core/modules/ml_utils.py +332 -0
- bibex_core/modules/post_process.py +50 -0
- bibex_core/modules/stats_utils.py +216 -0
- bibex_core/scp2xlsx.py +235 -0
- bibex_core/wos2xlsx.py +159 -0
- bibex_core/xlsx2vos.py +185 -0
- bibexpy/__init__.py +9 -0
- bibexpy/__main__.py +6 -0
- bibexpy/_server/__init__.py +1 -0
- bibexpy/_server/config.py +92 -0
- bibexpy/_server/jobs/__init__.py +3 -0
- bibexpy/_server/jobs/runner.py +301 -0
- bibexpy/_server/main.py +211 -0
- bibexpy/_server/models/__init__.py +0 -0
- bibexpy/_server/models/project.py +25 -0
- bibexpy/_server/routers/__init__.py +0 -0
- bibexpy/_server/routers/audit.py +65 -0
- bibexpy/_server/routers/convert.py +101 -0
- bibexpy/_server/routers/disambiguate.py +177 -0
- bibexpy/_server/routers/downloads.py +26 -0
- bibexpy/_server/routers/enrich.py +42 -0
- bibexpy/_server/routers/export.py +116 -0
- bibexpy/_server/routers/export_folder.py +97 -0
- bibexpy/_server/routers/filter.py +131 -0
- bibexpy/_server/routers/jobs.py +72 -0
- bibexpy/_server/routers/merge.py +437 -0
- bibexpy/_server/routers/prepare.py +241 -0
- bibexpy/_server/routers/projects.py +49 -0
- bibexpy/_server/routers/quality.py +288 -0
- bibexpy/_server/routers/records.py +204 -0
- bibexpy/_server/routers/report.py +111 -0
- bibexpy/_server/routers/settings.py +383 -0
- bibexpy/_server/routers/system.py +209 -0
- bibexpy/_server/routers/tools.py +257 -0
- bibexpy/_server/routers/upload.py +110 -0
- bibexpy/_server/services/__init__.py +0 -0
- bibexpy/_server/services/analyses.py +364 -0
- bibexpy/_server/services/audit.py +340 -0
- bibexpy/_server/services/bibex_adapter.py +47 -0
- bibexpy/_server/services/bibtex_writer.py +104 -0
- bibexpy/_server/services/converter.py +205 -0
- bibexpy/_server/services/disambiguation/__init__.py +19 -0
- bibexpy/_server/services/disambiguation/blocking.py +581 -0
- bibexpy/_server/services/disambiguation/cache.py +47 -0
- bibexpy/_server/services/disambiguation/deepseek_client.py +203 -0
- bibexpy/_server/services/disambiguation/orcid.py +261 -0
- bibexpy/_server/services/disambiguation/pipeline.py +985 -0
- bibexpy/_server/services/disambiguation/similarity.py +91 -0
- bibexpy/_server/services/enricher.py +338 -0
- bibexpy/_server/services/exporter.py +112 -0
- bibexpy/_server/services/filter_engine.py +300 -0
- bibexpy/_server/services/merger.py +202 -0
- bibexpy/_server/services/methodology.py +102 -0
- bibexpy/_server/services/report_export.py +300 -0
- bibexpy/_server/services/ris_writer.py +62 -0
- bibexpy/_server/services/smart_merger.py +967 -0
- bibexpy/_server/services/storage.py +165 -0
- bibexpy/_web/404/index.html +1 -0
- bibexpy/_web/404.html +1 -0
- bibexpy/_web/_next/static/chunks/117-7ad5186126fa076d.js +2 -0
- bibexpy/_web/_next/static/chunks/156-75beffb8ae92a7f3.js +1 -0
- bibexpy/_web/_next/static/chunks/169-301c04fcec22686f.js +4 -0
- bibexpy/_web/_next/static/chunks/2-ecc1c36a5632ac61.js +1 -0
- bibexpy/_web/_next/static/chunks/244-be343e0f8aa00fb5.js +1 -0
- bibexpy/_web/_next/static/chunks/367-b7457c2b95f6932b.js +1 -0
- bibexpy/_web/_next/static/chunks/382-9cd163eed632e241.js +1 -0
- bibexpy/_web/_next/static/chunks/392-afa8bfa4c1a9893d.js +1 -0
- bibexpy/_web/_next/static/chunks/507-63607ded9f83c1aa.js +1 -0
- bibexpy/_web/_next/static/chunks/624-cae565632f47217d.js +1 -0
- bibexpy/_web/_next/static/chunks/739-a00c6b9c0f11813e.js +1 -0
- bibexpy/_web/_next/static/chunks/862-8e1bd5f93c082c02.js +1 -0
- bibexpy/_web/_next/static/chunks/app/_not-found/page-873ba59b5eb7c342.js +1 -0
- bibexpy/_web/_next/static/chunks/app/layout-0d5fc68ac8971a04.js +1 -0
- bibexpy/_web/_next/static/chunks/app/page-cf2bea5edc74459a.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/convert/page-ab6dd645ae113b64.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/disambiguate/page-dd98482cc47344e9.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/enrich/page-4727e431468514e9.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/export/page-f9005d164bb22cb0.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/layout-07755fda55b3ae1f.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/merge/page-45b95318ea7beccf.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/records/page-bfad80457da9eb0b.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/report/page-f05767e74158e995.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/upload/page-8df78ca9cdecbcb8.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/page-99960711ac6a13e9.js +1 -0
- bibexpy/_web/_next/static/chunks/app/settings/page-002df809d6c5a227.js +1 -0
- bibexpy/_web/_next/static/chunks/app/tools/page-27eee1c6a590859f.js +1 -0
- bibexpy/_web/_next/static/chunks/fd9d1056-f2718be92181f426.js +1 -0
- bibexpy/_web/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
- bibexpy/_web/_next/static/chunks/main-app-d138a6e2f8c847f6.js +1 -0
- bibexpy/_web/_next/static/chunks/main-c4b9ddbf514503ef.js +1 -0
- bibexpy/_web/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
- bibexpy/_web/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
- bibexpy/_web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- bibexpy/_web/_next/static/chunks/webpack-c81f7fd28659d64f.js +1 -0
- bibexpy/_web/_next/static/css/1c4638f3e39cf127.css +3 -0
- bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_buildManifest.js +1 -0
- bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_ssgManifest.js +1 -0
- bibexpy/_web/images/PROMPTS.md +83 -0
- bibexpy/_web/images/about-illustration.png +0 -0
- bibexpy/_web/images/authors/alperen-sahin.jpg +0 -0
- bibexpy/_web/images/authors/burak-can-kara.jpg +0 -0
- bibexpy/_web/images/authors/taskin-dirsehan.jpg +0 -0
- bibexpy/_web/images/bibexpy-logo-full.png +0 -0
- bibexpy/_web/images/bibexpy-logo-header.png +0 -0
- bibexpy/_web/images/bibexpy-logo-in.png +0 -0
- bibexpy/_web/images/bibexpy-logo.webp +0 -0
- bibexpy/_web/images/hero-illustration.png +0 -0
- bibexpy/_web/images/loader.gif +0 -0
- bibexpy/_web/images/workflow-diagram.png +0 -0
- bibexpy/_web/index.html +1 -0
- bibexpy/_web/index.txt +11 -0
- bibexpy/_web/projects/_/convert/index.html +1 -0
- bibexpy/_web/projects/_/convert/index.txt +12 -0
- bibexpy/_web/projects/_/disambiguate/index.html +1 -0
- bibexpy/_web/projects/_/disambiguate/index.txt +12 -0
- bibexpy/_web/projects/_/enrich/index.html +1 -0
- bibexpy/_web/projects/_/enrich/index.txt +12 -0
- bibexpy/_web/projects/_/export/index.html +1 -0
- bibexpy/_web/projects/_/export/index.txt +12 -0
- bibexpy/_web/projects/_/merge/index.html +1 -0
- bibexpy/_web/projects/_/merge/index.txt +12 -0
- bibexpy/_web/projects/_/records/index.html +1 -0
- bibexpy/_web/projects/_/records/index.txt +12 -0
- bibexpy/_web/projects/_/report/index.html +1 -0
- bibexpy/_web/projects/_/report/index.txt +12 -0
- bibexpy/_web/projects/_/upload/index.html +1 -0
- bibexpy/_web/projects/_/upload/index.txt +12 -0
- bibexpy/_web/projects/index.html +1 -0
- bibexpy/_web/projects/index.txt +11 -0
- bibexpy/_web/settings/index.html +1 -0
- bibexpy/_web/settings/index.txt +11 -0
- bibexpy/_web/tools/README.md +65 -0
- bibexpy/_web/tools/bibexcel.svg +6 -0
- bibexpy/_web/tools/bibliometrix.png +0 -0
- bibexpy/_web/tools/biblioshiny.svg +12 -0
- bibexpy/_web/tools/citavi.svg +6 -0
- bibexpy/_web/tools/citespace.svg +7 -0
- bibexpy/_web/tools/citnetexplorer.svg +8 -0
- bibexpy/_web/tools/endnote.svg +6 -0
- bibexpy/_web/tools/excel.svg +1 -0
- bibexpy/_web/tools/gephi.svg +66 -0
- bibexpy/_web/tools/histcite.svg +7 -0
- bibexpy/_web/tools/index.html +1 -0
- bibexpy/_web/tools/index.txt +11 -0
- bibexpy/_web/tools/jabref.svg +122 -0
- bibexpy/_web/tools/latex.svg +1 -0
- bibexpy/_web/tools/mendeley.svg +1 -0
- bibexpy/_web/tools/openrefine.svg +15 -0
- bibexpy/_web/tools/overleaf.svg +1 -0
- bibexpy/_web/tools/papers.svg +7 -0
- bibexpy/_web/tools/powerbi.svg +1 -0
- bibexpy/_web/tools/python.svg +1 -0
- bibexpy/_web/tools/r.svg +1 -0
- bibexpy/_web/tools/refworks.svg +5 -0
- bibexpy/_web/tools/scite.svg +5 -0
- bibexpy/_web/tools/tableau.svg +1 -0
- bibexpy/_web/tools/vosviewer.png +0 -0
- bibexpy/_web/tools/zotero.svg +1 -0
- bibexpy/cli.py +242 -0
- bibexpy-2.0.0.dist-info/METADATA +105 -0
- bibexpy-2.0.0.dist-info/RECORD +171 -0
- bibexpy-2.0.0.dist-info/WHEEL +5 -0
- bibexpy-2.0.0.dist-info/entry_points.txt +2 -0
- bibexpy-2.0.0.dist-info/licenses/LICENSE +674 -0
- bibexpy-2.0.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""DeepSeek API minimum istemcisi (OpenAI uyumlu chat completions)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DeepSeekError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DeepSeekClient:
|
|
17
|
+
def __init__(self, api_key: str, base_url: str = "https://api.deepseek.com", model: str = "deepseek-v4-flash"):
|
|
18
|
+
if not api_key:
|
|
19
|
+
raise DeepSeekError("DEEPSEEK_API_KEY is not set")
|
|
20
|
+
self.api_key = api_key
|
|
21
|
+
self.base_url = base_url.rstrip("/")
|
|
22
|
+
self.model = model
|
|
23
|
+
|
|
24
|
+
def _post(self, payload: dict) -> dict:
|
|
25
|
+
url = f"{self.base_url}/chat/completions"
|
|
26
|
+
headers = {
|
|
27
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
}
|
|
30
|
+
r = requests.post(url, headers=headers, json=payload, timeout=60)
|
|
31
|
+
if not r.ok:
|
|
32
|
+
raise DeepSeekError(f"DeepSeek {r.status_code}: {r.text[:200]}")
|
|
33
|
+
return r.json()
|
|
34
|
+
|
|
35
|
+
def chat_json(self, system: str, user_payload: dict, temperature: float = 0.0) -> dict:
|
|
36
|
+
"""JSON yanıt iste. Prompt 'json' kelimesini içermek zorunda (DeepSeek kuralı)."""
|
|
37
|
+
body = {
|
|
38
|
+
"model": self.model,
|
|
39
|
+
"messages": [
|
|
40
|
+
{"role": "system", "content": system},
|
|
41
|
+
{"role": "user", "content": json.dumps(user_payload, ensure_ascii=False)},
|
|
42
|
+
],
|
|
43
|
+
"response_format": {"type": "json_object"},
|
|
44
|
+
"temperature": temperature,
|
|
45
|
+
}
|
|
46
|
+
data = self._post(body)
|
|
47
|
+
try:
|
|
48
|
+
content = data["choices"][0]["message"]["content"]
|
|
49
|
+
return json.loads(content)
|
|
50
|
+
except (KeyError, IndexError, json.JSONDecodeError) as e:
|
|
51
|
+
raise DeepSeekError(f"Could not parse response: {e}; raw={data}")
|
|
52
|
+
|
|
53
|
+
async def chat_json_async(self, system: str, user_payload: dict, temperature: float = 0.0) -> dict:
|
|
54
|
+
return await asyncio.to_thread(self.chat_json, system, user_payload, temperature)
|
|
55
|
+
|
|
56
|
+
def chat_text(self, system: str, user_text: str, temperature: float = 0.3) -> str:
|
|
57
|
+
"""Serbest metin yanıt iste (JSON zorlaması yok). Metodoloji raporu gibi
|
|
58
|
+
düz prose çıktılar için kullanılır."""
|
|
59
|
+
body = {
|
|
60
|
+
"model": self.model,
|
|
61
|
+
"messages": [
|
|
62
|
+
{"role": "system", "content": system},
|
|
63
|
+
{"role": "user", "content": user_text},
|
|
64
|
+
],
|
|
65
|
+
"temperature": temperature,
|
|
66
|
+
}
|
|
67
|
+
data = self._post(body)
|
|
68
|
+
try:
|
|
69
|
+
return data["choices"][0]["message"]["content"]
|
|
70
|
+
except (KeyError, IndexError) as e:
|
|
71
|
+
raise DeepSeekError(f"Could not parse response: {e}; raw={data}")
|
|
72
|
+
|
|
73
|
+
async def chat_text_async(self, system: str, user_text: str, temperature: float = 0.3) -> str:
|
|
74
|
+
return await asyncio.to_thread(self.chat_text, system, user_text, temperature)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
AUTHOR_SYSTEM_PROMPT = """You are a bibliometric data-quality expert. Your task: review the candidate author set provided by the user and determine which variants belong to the SAME person.
|
|
78
|
+
|
|
79
|
+
Signals to use when deciding:
|
|
80
|
+
- Name variants (initial variation, transliteration, married surname)
|
|
81
|
+
- Overlapping affiliations or known mobility (change of university)
|
|
82
|
+
- Shared coauthors (strong signal)
|
|
83
|
+
- Consistency of research field
|
|
84
|
+
- Chronological consistency of the year range
|
|
85
|
+
|
|
86
|
+
Return the answer in this JSON schema:
|
|
87
|
+
{
|
|
88
|
+
"clusters": [
|
|
89
|
+
{
|
|
90
|
+
"cluster_id": "c1",
|
|
91
|
+
"member_ids": ["v1", "v3"], (use the EXACT candidate "id" values provided, e.g. "v1","v2"; do NOT invent new labels)
|
|
92
|
+
"confidence": 0.0-1.0,
|
|
93
|
+
"reason": "short explanation (English)"
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
"uncertain": [
|
|
97
|
+
{"id": "z", "reason": "why it is uncertain (English)"}
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
Only place decisions with confidence>=0.7 into the same cluster. List uncertain ones in the "uncertain" list.
|
|
102
|
+
Respond with JSON only, write no other text."""
|
|
103
|
+
|
|
104
|
+
AUTHOR_SPLIT_SYSTEM_PROMPT = """You are a bibliometric data-quality expert. Your task: decide whether a SINGLE author name spelling (e.g., "Mehmet A") should be split into record groups belonging to different research fields.
|
|
105
|
+
|
|
106
|
+
Question: Do these records belong to DIFFERENT people sharing the SAME name (split), or to a SINGLE person working across disciplines (keep)?
|
|
107
|
+
|
|
108
|
+
Signals:
|
|
109
|
+
- If the research fields/categories are completely far apart (e.g., pure Physics vs pure Literature), the probability of different people is high -> split.
|
|
110
|
+
- If the fields are neighboring/related or there is an interdisciplinary topic, it may be a single person -> keep.
|
|
111
|
+
- Some records may lack field information; take this uncertainty into account.
|
|
112
|
+
|
|
113
|
+
Return the answer in this JSON schema:
|
|
114
|
+
{
|
|
115
|
+
"decision": "split" | "keep",
|
|
116
|
+
"confidence": 0.0-1.0,
|
|
117
|
+
"reason": "short explanation (English)"
|
|
118
|
+
}
|
|
119
|
+
Respond with JSON only, write no other text."""
|
|
120
|
+
|
|
121
|
+
AFFILIATION_SYSTEM_PROMPT = """You are an institution-name normalization expert. Your task: determine which of the given affiliation variants belong to the SAME institution.
|
|
122
|
+
|
|
123
|
+
Even if the institutional hierarchy (university > faculty > department) looks different, variants may be in the same cluster when they share the same parent institution; however, separate campuses/cities belong to different clusters.
|
|
124
|
+
|
|
125
|
+
Return the answer in this JSON schema:
|
|
126
|
+
{
|
|
127
|
+
"clusters": [
|
|
128
|
+
{
|
|
129
|
+
"cluster_id": "c1",
|
|
130
|
+
"member_ids": ["v1", "v3"], (use the EXACT candidate "id" values provided, e.g. "v1","v2"; do NOT invent new labels)
|
|
131
|
+
"canonical_name": "Suggested standard spelling (official English form)",
|
|
132
|
+
"country": "ISO 3166-1 alpha-2 or empty",
|
|
133
|
+
"confidence": 0.0-1.0,
|
|
134
|
+
"reason": "short explanation (English)"
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
"uncertain": [
|
|
138
|
+
{"id": "z", "reason": "..."}
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
Respond with JSON only, write no other text."""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
METHODOLOGY_SYSTEM_PROMPT = """You are an academic writing assistant. You help researchers draft the "Data Collection and Preparation" (Methodology) subsection of a bibliometric study.
|
|
146
|
+
|
|
147
|
+
You will receive a chronological JSON log of operations the researcher performed in BibexPy, a tool that merges, cleans, filters, enriches and disambiguates bibliographic records exported from Web of Science (WoS) and Scopus.
|
|
148
|
+
|
|
149
|
+
Write a clear, formal methodology narrative IN ENGLISH describing the data preparation workflow. Requirements:
|
|
150
|
+
- Use academic past tense and passive voice (e.g., "A total of N records were retrieved from...", "After deduplication, M unique records remained...", "Records published before YYYY were excluded...").
|
|
151
|
+
- Report the SPECIFIC numbers that appear in the log (input counts, merged/unique counts, removed duplicates, excluded/filtered records, enriched fields, normalized/split author names).
|
|
152
|
+
- Follow the actual chronological order of the steps as logged.
|
|
153
|
+
- Name the source databases (Web of Science and/or Scopus) and state that data preparation was performed using BibexPy, with an APA-style in-text citation "(Kara et al., 2025)".
|
|
154
|
+
- When duplicate removal or record linkage is described, you may note that it followed established record-linkage practice; when author name handling is described, refer to it as author name disambiguation (normalization of name variants and separation of homonymous names).
|
|
155
|
+
- Do NOT invent steps, numbers, criteria, or external citations that are not present in the log — the ONLY citation you must add is the BibexPy reference specified below. If a detail is missing, omit it rather than fabricating.
|
|
156
|
+
- Be concise and publication-ready: one to a few short paragraphs. You MAY use brief Markdown subheadings (e.g., "## Data sources", "## Deduplication and merging", "## Screening and enrichment", "## Author disambiguation") if it improves clarity.
|
|
157
|
+
- End with a short "## Reference" section that contains EXACTLY this reference (do not alter it): Kara, B. C., Şahin, A., & Dirsehan, T. (2025). BibexPy: Harmonizing the bibliometric symphony of Scopus and Web of Science. SoftwareX, 30, 102098. https://doi.org/10.1016/j.softx.2025.102098
|
|
158
|
+
|
|
159
|
+
Output Markdown text only. Do not add a preamble like "Here is the methodology"; start directly with the content."""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
COUNTRY_SYSTEM_PROMPT = """You are a bibliometric data-quality expert. You receive tokens taken from the LAST component of author addresses (the C1 field) that did NOT match the automatic country dictionary.
|
|
163
|
+
|
|
164
|
+
Some tokens ARE country-name spellings the dictionary missed (abbreviation, typo, local or former name, e.g. "U.S.A.", "Peoples R China", "Deutschland") — group THESE by the country they denote.
|
|
165
|
+
|
|
166
|
+
Other tokens are NOT countries at all (institutions, cities, fragments, e.g. "Michigan State University", "Hangzhou") — put EACH of these in "uncertain". NEVER cluster a non-country token as a country and NEVER map an institution/city to a country here. (Missing countries are completed separately from the API, not guessed here.)
|
|
167
|
+
|
|
168
|
+
Return the answer in this JSON schema:
|
|
169
|
+
{
|
|
170
|
+
"clusters": [
|
|
171
|
+
{
|
|
172
|
+
"cluster_id": "c1",
|
|
173
|
+
"member_ids": ["v1", "v3"], (use the EXACT candidate "id" values provided, e.g. "v1","v2"; do NOT invent new labels)
|
|
174
|
+
"canonical_name": "Canonical English country name (ISO 3166, e.g., United States)",
|
|
175
|
+
"confidence": 0.0-1.0,
|
|
176
|
+
"reason": "short explanation (English)"
|
|
177
|
+
}
|
|
178
|
+
],
|
|
179
|
+
"uncertain": [ {"id": "z", "reason": "not a country (e.g., institution / city / fragment)"} ]
|
|
180
|
+
}
|
|
181
|
+
Respond with JSON only, write no other text."""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
ORG_ROLLUP_SYSTEM_PROMPT = """You are a bibliometric data-quality expert. Your task: group the given FULL address (affiliation) strings by the PARENT INSTITUTION (university / research institution) they belong to.
|
|
185
|
+
|
|
186
|
+
Goal: build the collaboration network through the PARENT INSTITUTION rather than the department/sub-unit. Different departments or spelling variants of the same parent institution should be in a single cluster; DIFFERENT campuses/cities or different institutions must stay in SEPARATE clusters.
|
|
187
|
+
|
|
188
|
+
canonical_name: the standard name of the parent institution — WITHOUT department and geography (e.g., "University of Oxford").
|
|
189
|
+
|
|
190
|
+
Return the answer in this JSON schema:
|
|
191
|
+
{
|
|
192
|
+
"clusters": [
|
|
193
|
+
{
|
|
194
|
+
"cluster_id": "c1",
|
|
195
|
+
"member_ids": ["v1", "v3"], (use the EXACT candidate "id" values provided, e.g. "v1","v2"; do NOT invent new labels)
|
|
196
|
+
"canonical_name": "Parent institution standard name",
|
|
197
|
+
"confidence": 0.0-1.0,
|
|
198
|
+
"reason": "short explanation (English)"
|
|
199
|
+
}
|
|
200
|
+
],
|
|
201
|
+
"uncertain": [ {"id": "z", "reason": "why uncertain (English)"} ]
|
|
202
|
+
}
|
|
203
|
+
Respond with JSON only, write no other text."""
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""ORCID-öncelikli yazar disambiguation yardımcıları.
|
|
2
|
+
|
|
3
|
+
Mantık: bir ambigu yazar grubu için kimlik kararını ÖNCE veride var olan ORCID
|
|
4
|
+
(OI sütunu) ile, yoksa o grubun çalışmalarının DOI'lerinden OpenAlex'e ANLIK
|
|
5
|
+
sorgu atarak verir. ORCID altın standarttır:
|
|
6
|
+
• Aynı ORCID → kesin aynı kişi (alan/isim farkı önemsiz).
|
|
7
|
+
• Farklı ORCID → kesin farklı kişi (isim aynı olsa bile).
|
|
8
|
+
|
|
9
|
+
Bu modül framework-bağımsız tutulmaya çalışılır; yalnız api_utils'in HTTP
|
|
10
|
+
yardımcısını (timeout + 429 backoff) ve similarity.normalize_name'i kullanır.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
|
|
17
|
+
from bibex_core.modules.api_utils import _get_with_retry
|
|
18
|
+
from .similarity import normalize_name
|
|
19
|
+
from .blocking import _SPLIT_SUFFIX_RE
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _clean_orcid(v) -> str:
|
|
23
|
+
"""https://orcid.org/0000-... → 0000-... (sade ORCID)."""
|
|
24
|
+
return (
|
|
25
|
+
str(v or "")
|
|
26
|
+
.replace("https://orcid.org/", "")
|
|
27
|
+
.replace("http://orcid.org/", "")
|
|
28
|
+
.strip()
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _norm(name: str) -> str:
|
|
33
|
+
"""İsim eşleştirme anahtarı: split eki ('(b)') soyulmuş + normalize."""
|
|
34
|
+
return normalize_name(_SPLIT_SUFFIX_RE.sub("", str(name or "")))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@lru_cache(maxsize=8192)
|
|
38
|
+
def fetch_orcids_for_doi(doi: str, email: str | None = None) -> tuple:
|
|
39
|
+
"""OpenAlex'ten bir DOI için YALNIZ yazar-ORCID/ROR/ülke çek (hafif: select=authorships).
|
|
40
|
+
|
|
41
|
+
Dönüş: ((display_name, orcid, ror, cc), ...) tuple'ı (lru_cache hashable olsun diye).
|
|
42
|
+
Hata/boş → boş tuple. Tam metadata çekmez (~%95 daha küçük payload).
|
|
43
|
+
"""
|
|
44
|
+
d = str(doi or "").strip()
|
|
45
|
+
if not d:
|
|
46
|
+
return tuple()
|
|
47
|
+
url = f"https://api.openalex.org/works/doi:{d}?select=authorships,doi"
|
|
48
|
+
if email:
|
|
49
|
+
url += f"&mailto={email}"
|
|
50
|
+
try:
|
|
51
|
+
r = _get_with_retry(url)
|
|
52
|
+
except Exception:
|
|
53
|
+
return tuple()
|
|
54
|
+
if getattr(r, "status_code", None) != 200:
|
|
55
|
+
return tuple()
|
|
56
|
+
try:
|
|
57
|
+
work = r.json()
|
|
58
|
+
except Exception:
|
|
59
|
+
return tuple()
|
|
60
|
+
|
|
61
|
+
out: list[tuple] = []
|
|
62
|
+
for a in (work.get("authorships") or []):
|
|
63
|
+
au = a.get("author") or {}
|
|
64
|
+
name = au.get("display_name") or ""
|
|
65
|
+
orcid = _clean_orcid(au.get("orcid"))
|
|
66
|
+
rors, ccs = [], []
|
|
67
|
+
for inst in (a.get("institutions") or []):
|
|
68
|
+
if inst.get("ror"):
|
|
69
|
+
rors.append(str(inst["ror"]).replace("https://ror.org/", "").strip())
|
|
70
|
+
if inst.get("country_code"):
|
|
71
|
+
ccs.append(str(inst["country_code"]).strip().upper())
|
|
72
|
+
for cc in (a.get("countries") or []):
|
|
73
|
+
if cc:
|
|
74
|
+
ccs.append(str(cc).strip().upper())
|
|
75
|
+
out.append((name, orcid, "; ".join(dict.fromkeys(rors)), "; ".join(dict.fromkeys(ccs))))
|
|
76
|
+
return tuple(out)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@lru_cache(maxsize=8192)
|
|
80
|
+
def fetch_affiliations_for_doi(doi: str, email: str | None = None) -> tuple:
|
|
81
|
+
"""OpenAlex'ten bir DOI için (kurum_adı, kanonik_ülke) çiftlerini çek (adres tamamlama).
|
|
82
|
+
|
|
83
|
+
Dönüş: ((institution_display_name, country_name), ...) — country_name kanonik
|
|
84
|
+
İngilizce ad (country_code'dan çevrilir; bilinmiyorsa ''). Hata/boş → boş tuple.
|
|
85
|
+
Hafif: select=authorships. lru_cache ile DOI tekrar çekilmez.
|
|
86
|
+
"""
|
|
87
|
+
from bibex_core.modules.c1_utils import country_from_code
|
|
88
|
+
|
|
89
|
+
d = str(doi or "").strip()
|
|
90
|
+
if not d:
|
|
91
|
+
return tuple()
|
|
92
|
+
url = f"https://api.openalex.org/works/doi:{d}?select=authorships,doi"
|
|
93
|
+
if email:
|
|
94
|
+
url += f"&mailto={email}"
|
|
95
|
+
try:
|
|
96
|
+
r = _get_with_retry(url)
|
|
97
|
+
except Exception:
|
|
98
|
+
return tuple()
|
|
99
|
+
if getattr(r, "status_code", None) != 200:
|
|
100
|
+
return tuple()
|
|
101
|
+
try:
|
|
102
|
+
work = r.json()
|
|
103
|
+
except Exception:
|
|
104
|
+
return tuple()
|
|
105
|
+
|
|
106
|
+
out: list[tuple] = []
|
|
107
|
+
seen: set = set()
|
|
108
|
+
for a in (work.get("authorships") or []):
|
|
109
|
+
for inst in (a.get("institutions") or []):
|
|
110
|
+
name = str(inst.get("display_name") or "").strip()
|
|
111
|
+
country = country_from_code(inst.get("country_code") or "") or ""
|
|
112
|
+
key = (name.lower(), country)
|
|
113
|
+
if (name or country) and key not in seen:
|
|
114
|
+
seen.add(key)
|
|
115
|
+
out.append((name, country))
|
|
116
|
+
return tuple(out)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_oi_cell(cell) -> dict[str, str]:
|
|
120
|
+
"""WoS OI hücresini ('Ad/0000-...; Ad2/0000-...') {normalize_name(ad): orcid} yap.
|
|
121
|
+
|
|
122
|
+
Hem '/' (WoS) hem ', ' varyantlarını tolere eder; ad yoksa atlar.
|
|
123
|
+
"""
|
|
124
|
+
s = str(cell or "").strip()
|
|
125
|
+
if not s or s.lower() in ("nan", "none"):
|
|
126
|
+
return {}
|
|
127
|
+
out: dict[str, str] = {}
|
|
128
|
+
for part in s.split(";"):
|
|
129
|
+
part = part.strip()
|
|
130
|
+
if not part:
|
|
131
|
+
continue
|
|
132
|
+
# 'Ad/ORCID' biçimi
|
|
133
|
+
if "/" in part:
|
|
134
|
+
name, _, oid = part.rpartition("/")
|
|
135
|
+
else:
|
|
136
|
+
# yalnız ORCID olabilir — ada bağlanamaz, atla
|
|
137
|
+
continue
|
|
138
|
+
oid = _clean_orcid(oid)
|
|
139
|
+
nm = _norm(name)
|
|
140
|
+
if nm and oid:
|
|
141
|
+
out[nm] = oid
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def orcids_for_candidate(df, candidate: dict, *, email: str | None, allow_api: bool) -> set[str]:
|
|
146
|
+
"""Bir adayın (candidate) ORCID kümesi.
|
|
147
|
+
|
|
148
|
+
candidate['records'] satır indeksleri + candidate['name_variants'] (ya da
|
|
149
|
+
'variants') üzerinden: ÖNCE her satırın OI hücresinden, eksikse (allow_api ise)
|
|
150
|
+
DOI'den OpenAlex sorgusuyla, adayın isim varyantlarıyla EŞLEŞEN yazarların
|
|
151
|
+
ORCID'lerini toplar. İsim eşleşmesi şart — yoksa ko-yazarın ORCID'i çekilmez.
|
|
152
|
+
|
|
153
|
+
Yan etki YOK (veriye yazmaz); yalnız okur. (Write-back ayrı yapılır.)
|
|
154
|
+
"""
|
|
155
|
+
variants = candidate.get("name_variants") or candidate.get("variants") or []
|
|
156
|
+
want = {_norm(v) for v in variants if v}
|
|
157
|
+
if candidate.get("id"):
|
|
158
|
+
want.add(_norm(candidate["id"]))
|
|
159
|
+
want.discard("")
|
|
160
|
+
if not want:
|
|
161
|
+
return set()
|
|
162
|
+
|
|
163
|
+
records = candidate.get("records") or []
|
|
164
|
+
has_oi = "OI" in df.columns
|
|
165
|
+
has_di = "DI" in df.columns
|
|
166
|
+
found: set[str] = set()
|
|
167
|
+
|
|
168
|
+
for idx in records:
|
|
169
|
+
if idx not in df.index:
|
|
170
|
+
continue
|
|
171
|
+
# 1) Veride var olan OI
|
|
172
|
+
if has_oi:
|
|
173
|
+
for nm, oid in parse_oi_cell(df.at[idx, "OI"]).items():
|
|
174
|
+
if nm in want:
|
|
175
|
+
found.add(oid)
|
|
176
|
+
# 2) Eksikse DOI'den anlık (yalnız bu satırda eşleşen isim için)
|
|
177
|
+
if allow_api and has_di:
|
|
178
|
+
doi = df.at[idx, "DI"]
|
|
179
|
+
if doi is not None and str(doi).strip() and str(doi).strip().lower() != "nan":
|
|
180
|
+
for name, orcid, _ror, _cc in fetch_orcids_for_doi(str(doi).strip(), email):
|
|
181
|
+
if orcid and _norm(name) in want:
|
|
182
|
+
found.add(orcid)
|
|
183
|
+
return found
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _is_blank(v) -> bool:
|
|
187
|
+
s = "" if v is None else str(v).strip()
|
|
188
|
+
return s == "" or s.lower() in ("nan", "none")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def enrich_rows_from_doi(df, row_indices, *, email: str | None) -> dict:
|
|
192
|
+
"""Verilen satırların BOŞ OI/RI/ROR/CC hücrelerini DOI üzerinden OpenAlex ile doldur.
|
|
193
|
+
|
|
194
|
+
Yalnız boş hücreye yazar (mevcut/özgün değeri ezmez). df YERİNDE güncellenir.
|
|
195
|
+
Dönüş: {'rows_filled': int, 'dois_fetched': int}. Yan etki: df mutasyonu.
|
|
196
|
+
"""
|
|
197
|
+
if "DI" not in df.columns:
|
|
198
|
+
return {"rows_filled": 0, "dois_fetched": 0}
|
|
199
|
+
# Yeni kolonlar yoksa oluştur (object dtype) — boş hücreye yazım için
|
|
200
|
+
for col in ("OI", "RI", "ROR", "CC"):
|
|
201
|
+
if col not in df.columns:
|
|
202
|
+
df[col] = None
|
|
203
|
+
|
|
204
|
+
rows_filled = 0
|
|
205
|
+
dois_fetched = 0
|
|
206
|
+
seen_doi: set[str] = set()
|
|
207
|
+
for idx in dict.fromkeys(row_indices): # tekilleştir, sırayı koru
|
|
208
|
+
if idx not in df.index:
|
|
209
|
+
continue
|
|
210
|
+
doi = df.at[idx, "DI"]
|
|
211
|
+
if doi is None or not str(doi).strip() or str(doi).strip().lower() == "nan":
|
|
212
|
+
continue
|
|
213
|
+
d = str(doi).strip()
|
|
214
|
+
rows = fetch_orcids_for_doi(d, email) # lru_cache'li
|
|
215
|
+
if d not in seen_doi:
|
|
216
|
+
seen_doi.add(d)
|
|
217
|
+
if rows:
|
|
218
|
+
dois_fetched += 1
|
|
219
|
+
if not rows:
|
|
220
|
+
continue
|
|
221
|
+
# Satır-seviyesi birleşik stringler (WoS formatı)
|
|
222
|
+
ois = [f"{n}/{o}" for (n, o, _r, _c) in rows if n and o]
|
|
223
|
+
ris = [f"{n}/{o}" for (n, o, _r, _c) in rows if n] # RI yok; ad/id yerine ad/orcid yedeği
|
|
224
|
+
rors, ccs = [], []
|
|
225
|
+
for (_n, _o, r, c) in rows:
|
|
226
|
+
if r:
|
|
227
|
+
rors += [t.strip() for t in r.split(";") if t.strip()]
|
|
228
|
+
if c:
|
|
229
|
+
ccs += [t.strip() for t in c.split(";") if t.strip()]
|
|
230
|
+
updates = {
|
|
231
|
+
"OI": "; ".join(dict.fromkeys(ois)),
|
|
232
|
+
"ROR": "; ".join(dict.fromkeys(rors)),
|
|
233
|
+
"CC": "; ".join(dict.fromkeys(ccs)),
|
|
234
|
+
}
|
|
235
|
+
changed = False
|
|
236
|
+
for col, val in updates.items():
|
|
237
|
+
if val and _is_blank(df.at[idx, col]):
|
|
238
|
+
df.at[idx, col] = val
|
|
239
|
+
changed = True
|
|
240
|
+
if changed:
|
|
241
|
+
rows_filled += 1
|
|
242
|
+
return {"rows_filled": rows_filled, "dois_fetched": dois_fetched}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def classify_by_orcid(orcid_sets: list[set[str]]) -> str:
|
|
246
|
+
"""Üye-başına ORCID kümelerinden grup kararı.
|
|
247
|
+
|
|
248
|
+
'merge' — ORCID'i bilinen üyeler TEK ortak ORCID'de buluşuyor → aynı kişi
|
|
249
|
+
'conflict' — iki üye ayrık (kesişimsiz) ORCID taşıyor → farklı kişi
|
|
250
|
+
'unknown' — yetersiz ORCID kanıtı → isim+alan heuristiği + LLM'e bırak
|
|
251
|
+
"""
|
|
252
|
+
known = [s for s in orcid_sets if s]
|
|
253
|
+
if len(known) < 2:
|
|
254
|
+
return "unknown"
|
|
255
|
+
# Herhangi iki bilinen küme kesişmiyorsa → çelişki (farklı kişiler)
|
|
256
|
+
for i in range(len(known)):
|
|
257
|
+
for j in range(i + 1, len(known)):
|
|
258
|
+
if not (known[i] & known[j]):
|
|
259
|
+
return "conflict"
|
|
260
|
+
# Tüm bilinen kümeler ikili kesişiyor → ortak ORCID(ler) var → aynı kişi
|
|
261
|
+
return "merge"
|