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,91 @@
|
|
|
1
|
+
"""İsim/affiliation benzerlik fonksiyonları — saf Python, dış kütüphane yok."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize_name(s: str) -> str:
|
|
10
|
+
"""ASCII transliteration + lower + tek boşluk."""
|
|
11
|
+
if not s:
|
|
12
|
+
return ""
|
|
13
|
+
s = unicodedata.normalize("NFKD", str(s)).encode("ascii", "ignore").decode("ascii")
|
|
14
|
+
s = s.lower()
|
|
15
|
+
s = re.sub(r"[^a-z0-9 ]+", " ", s)
|
|
16
|
+
s = re.sub(r"\s+", " ", s).strip()
|
|
17
|
+
return s
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def jaro(s1: str, s2: str) -> float:
|
|
21
|
+
if s1 == s2:
|
|
22
|
+
return 1.0
|
|
23
|
+
if not s1 or not s2:
|
|
24
|
+
return 0.0
|
|
25
|
+
match_dist = max(len(s1), len(s2)) // 2 - 1
|
|
26
|
+
if match_dist < 0:
|
|
27
|
+
match_dist = 0
|
|
28
|
+
s1_matches = [False] * len(s1)
|
|
29
|
+
s2_matches = [False] * len(s2)
|
|
30
|
+
matches = 0
|
|
31
|
+
for i, c in enumerate(s1):
|
|
32
|
+
start = max(0, i - match_dist)
|
|
33
|
+
end = min(i + match_dist + 1, len(s2))
|
|
34
|
+
for j in range(start, end):
|
|
35
|
+
if s2_matches[j] or s2[j] != c:
|
|
36
|
+
continue
|
|
37
|
+
s1_matches[i] = s2_matches[j] = True
|
|
38
|
+
matches += 1
|
|
39
|
+
break
|
|
40
|
+
if matches == 0:
|
|
41
|
+
return 0.0
|
|
42
|
+
t = 0
|
|
43
|
+
k = 0
|
|
44
|
+
for i in range(len(s1)):
|
|
45
|
+
if not s1_matches[i]:
|
|
46
|
+
continue
|
|
47
|
+
while not s2_matches[k]:
|
|
48
|
+
k += 1
|
|
49
|
+
if s1[i] != s2[k]:
|
|
50
|
+
t += 1
|
|
51
|
+
k += 1
|
|
52
|
+
t /= 2
|
|
53
|
+
return (matches / len(s1) + matches / len(s2) + (matches - t) / matches) / 3
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def jaro_winkler(s1: str, s2: str, p: float = 0.1) -> float:
|
|
57
|
+
"""Jaro-Winkler benzerlik [0, 1]. Önek eşleşmesine bonus verir."""
|
|
58
|
+
j = jaro(s1, s2)
|
|
59
|
+
prefix = 0
|
|
60
|
+
for c1, c2 in zip(s1[:4], s2[:4]):
|
|
61
|
+
if c1 == c2:
|
|
62
|
+
prefix += 1
|
|
63
|
+
else:
|
|
64
|
+
break
|
|
65
|
+
return j + prefix * p * (1 - j)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def name_initials(full: str) -> tuple[str, str]:
|
|
69
|
+
"""Bibliyometrik yazar adından (soyad ÖNCE) soyad + baş harfler.
|
|
70
|
+
|
|
71
|
+
'Smith, John A' -> ('smith', 'ja')
|
|
72
|
+
'Smith J A' -> ('smith', 'ja')
|
|
73
|
+
'van der Berg, J' -> ('van der berg', 'j')
|
|
74
|
+
|
|
75
|
+
NOT: WoS/Scopus AU alanı soyadı başa yazar. Eski 'en uzun token = soyad'
|
|
76
|
+
sezgisi 'Jones, Robert' gibi adlarda (ad, soyaddan uzun) soyadı yanlış
|
|
77
|
+
seçip aynı kişiyi farklı bloklara dağıtıyordu — düzeltildi.
|
|
78
|
+
"""
|
|
79
|
+
raw = str(full or "")
|
|
80
|
+
if "," in raw:
|
|
81
|
+
# "Soyad, Ad ..." — virgülden öncesi soyad, sonrası baş harf kaynağı
|
|
82
|
+
surname_part, _, given_part = raw.partition(",")
|
|
83
|
+
surname = normalize_name(surname_part)
|
|
84
|
+
initials = "".join(t[0] for t in normalize_name(given_part).split() if t)
|
|
85
|
+
if surname:
|
|
86
|
+
return surname, initials
|
|
87
|
+
# Virgül yok: "Soyad Baş-harfler" — ilk token soyad, kalanlar baş harf
|
|
88
|
+
parts = normalize_name(raw).split()
|
|
89
|
+
if not parts:
|
|
90
|
+
return "", ""
|
|
91
|
+
return parts[0], "".join(p[0] for p in parts[1:] if p)
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""API enrichment sarmal — bibex_core'u kullanır.
|
|
2
|
+
|
|
3
|
+
Tek giriş noktası `run_fill_all`: aktif dataset'i bir kez okur, her DOI için API'yi
|
|
4
|
+
TEK SEFER çağırıp dönen tüm boş alanları doldurur (fetch-once-fill-all) ve AKTİF
|
|
5
|
+
dataset'e geri yazar. Böylece DOI başına ~7 yerine 1 API çağrısı yapılır ve sonuç
|
|
6
|
+
dashboard'ın okuduğu aktif dataset'e yansır. ML geçişi YOKTUR — yalnız API ile doldurma.
|
|
7
|
+
|
|
8
|
+
Tüm işler uzun sürer — job runner üzerinden çağrılır.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
from fastapi import HTTPException
|
|
20
|
+
|
|
21
|
+
from config import settings
|
|
22
|
+
from jobs.runner import JobContext
|
|
23
|
+
from services import analyses, filter_engine, merger, storage
|
|
24
|
+
from services.bibex_adapter import _suppress_stdio
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _active_path(project_id: str) -> Path:
|
|
28
|
+
"""Aktif analiz dataset'i — filter/quality/disambiguation bunu okur/yazar."""
|
|
29
|
+
src = merger.merged_dataset_path(project_id)
|
|
30
|
+
if src is None:
|
|
31
|
+
raise HTTPException(409, "no_merged_data")
|
|
32
|
+
return src
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _snapshot(project_id: str, df: pd.DataFrame, tag: str) -> str:
|
|
36
|
+
# Snapshot, AKTİF analiz klasörüne yazılır (proje kökü değil) — her merge kendi
|
|
37
|
+
# snapshot'larını taşır, yeni analiz eskileri devralmaz.
|
|
38
|
+
snaps = analyses.work_dir(project_id) / "snapshots"
|
|
39
|
+
snaps.mkdir(exist_ok=True)
|
|
40
|
+
stamp = time.strftime("%Y%m%d_%H%M%S")
|
|
41
|
+
p = snaps / f"pre_{tag}_{stamp}.xlsx"
|
|
42
|
+
df.to_excel(p, index=False)
|
|
43
|
+
return str(p.relative_to(storage.settings.storage_path))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_blank(v) -> bool:
|
|
47
|
+
if v is None:
|
|
48
|
+
return True
|
|
49
|
+
try:
|
|
50
|
+
if pd.isna(v):
|
|
51
|
+
return True
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
pass
|
|
54
|
+
return str(v).strip() in ("", "nan", "NaN")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Doluluk oranı raporu için izlenen alanlar (dashboard ile aynı küme)
|
|
58
|
+
_RATE_FIELDS = ("DI", "AB", "TI", "AU", "PY", "DE", "ID", "WC", "SC", "C1", "OI", "EM", "CR", "TC", "LA")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fill_rate(series) -> float:
|
|
62
|
+
total = len(series)
|
|
63
|
+
if not total:
|
|
64
|
+
return 0.0
|
|
65
|
+
filled = int(sum(0 if _is_blank(x) else 1 for x in series))
|
|
66
|
+
return filled / total
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _mirror_wc_sc(df: pd.DataFrame) -> int:
|
|
70
|
+
"""WC<->SC karşılıklı kopya (deterministik): biri doluysa boş olanı onunla doldur.
|
|
71
|
+
|
|
72
|
+
DOI/API gerektirmez — WoS kayıtlarındaki tek-eksik WC/SC'yi anında tamamlar.
|
|
73
|
+
Doldurulan hücre sayısını döndürür.
|
|
74
|
+
"""
|
|
75
|
+
if "WC" not in df.columns or "SC" not in df.columns:
|
|
76
|
+
return 0
|
|
77
|
+
wc_blank = df["WC"].map(_is_blank)
|
|
78
|
+
sc_blank = df["SC"].map(_is_blank)
|
|
79
|
+
m1 = (~wc_blank) & sc_blank # WC var, SC yok -> SC = WC
|
|
80
|
+
m2 = (~sc_blank) & wc_blank # SC var, WC yok -> WC = SC
|
|
81
|
+
df.loc[m1, "SC"] = df.loc[m1, "WC"]
|
|
82
|
+
df.loc[m2, "WC"] = df.loc[m2, "SC"]
|
|
83
|
+
return int(m1.sum() + m2.sum())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def _api_pass(ctx: JobContext, df: pd.DataFrame, *, lo: float = 0.05, hi: float = 0.95) -> dict[str, Any]:
|
|
87
|
+
"""df'i YERİNDE güncelle: her DOI için tek extract_metadata çağrısı, tüm boş alanları doldur."""
|
|
88
|
+
from bibex_core.modules.api_utils import extract_metadata
|
|
89
|
+
|
|
90
|
+
if "DI" not in df.columns:
|
|
91
|
+
ctx.log("No DI (DOI) column — API pass skipped")
|
|
92
|
+
return {"total": 0, "enriched": 0}
|
|
93
|
+
|
|
94
|
+
with_doi = df[df["DI"].astype(str).str.strip().ne("") & df["DI"].astype(str).str.upper().ne("NAN")]
|
|
95
|
+
total = len(with_doi)
|
|
96
|
+
ctx.log(f"API: scanning {total} records with a DOI")
|
|
97
|
+
|
|
98
|
+
creds = {
|
|
99
|
+
"scopus_api_key": settings.scopus_api_key or None,
|
|
100
|
+
"semantic_scholar_key": settings.semantic_scholar_api_key or None,
|
|
101
|
+
"unpaywall_email": settings.unpaywall_email or None,
|
|
102
|
+
"crossref_email": settings.crossref_email or None,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def _one(doi: str, current: dict):
|
|
106
|
+
with _suppress_stdio():
|
|
107
|
+
return extract_metadata(doi, current, **creds)
|
|
108
|
+
|
|
109
|
+
enriched = 0
|
|
110
|
+
for i, (idx, row) in enumerate(with_doi.iterrows()):
|
|
111
|
+
if ctx.cancelled:
|
|
112
|
+
ctx.log("Cancelled by user")
|
|
113
|
+
break
|
|
114
|
+
doi = str(row["DI"]).strip()
|
|
115
|
+
current = {c: row[c] for c in df.columns}
|
|
116
|
+
try:
|
|
117
|
+
new_data = await asyncio.to_thread(_one, doi, current)
|
|
118
|
+
updated = 0
|
|
119
|
+
for k, v in new_data.items():
|
|
120
|
+
if k in df.columns and _is_blank(df.at[idx, k]) and not _is_blank(v):
|
|
121
|
+
df.at[idx, k] = v
|
|
122
|
+
updated += 1
|
|
123
|
+
if updated:
|
|
124
|
+
enriched += 1
|
|
125
|
+
except Exception as e:
|
|
126
|
+
ctx.log(f"DOI {doi}: {e}")
|
|
127
|
+
|
|
128
|
+
if (i + 1) % 10 == 0 or i == total - 1:
|
|
129
|
+
ctx.progress(lo + (hi - lo) * ((i + 1) / max(1, total)))
|
|
130
|
+
ctx.log(f"API: {i + 1}/{total} (enriched: {enriched})")
|
|
131
|
+
|
|
132
|
+
return {"total": int(total), "enriched": int(enriched)}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _doi_pass(ctx: JobContext, df: pd.DataFrame, *, lo: float = 0.05, hi: float = 0.4) -> dict[str, Any]:
|
|
136
|
+
"""Eksik DOI'leri başlık+yıl+yazardan ters arayarak doldur (CrossRef + OpenAlex, doğrulamalı).
|
|
137
|
+
|
|
138
|
+
Dengeli eşik + otomatik yazma; emin olunmayan kayıt boş kalır. df YERİNDE güncellenir.
|
|
139
|
+
"""
|
|
140
|
+
from bibex_core.modules.api_utils import resolve_doi
|
|
141
|
+
|
|
142
|
+
if "DI" not in df.columns or "TI" not in df.columns:
|
|
143
|
+
return {"scanned": 0, "filled": 0}
|
|
144
|
+
|
|
145
|
+
blank_di = df[df["DI"].map(_is_blank) & ~df["TI"].map(_is_blank)]
|
|
146
|
+
total = len(blank_di)
|
|
147
|
+
ctx.log(f"DOI lookup: {total} records without a DOI")
|
|
148
|
+
if not total:
|
|
149
|
+
ctx.progress(hi)
|
|
150
|
+
return {"scanned": 0, "filled": 0}
|
|
151
|
+
|
|
152
|
+
email = settings.crossref_email or None
|
|
153
|
+
has_au, has_af, has_py = "AU" in df.columns, "AF" in df.columns, "PY" in df.columns
|
|
154
|
+
filled = 0
|
|
155
|
+
for i, (idx, row) in enumerate(blank_di.iterrows()):
|
|
156
|
+
if ctx.cancelled:
|
|
157
|
+
ctx.log("Cancelled by user")
|
|
158
|
+
break
|
|
159
|
+
title = row["TI"]
|
|
160
|
+
authors = row["AU"] if has_au and not _is_blank(row["AU"]) else (row["AF"] if has_af else None)
|
|
161
|
+
year = row["PY"] if has_py else None
|
|
162
|
+
try:
|
|
163
|
+
doi = await asyncio.to_thread(resolve_doi, title, authors, year, crossref_email=email)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
ctx.log(f"DOI lookup error: {e}")
|
|
166
|
+
doi = None
|
|
167
|
+
if doi:
|
|
168
|
+
df.at[idx, "DI"] = doi
|
|
169
|
+
filled += 1
|
|
170
|
+
if (i + 1) % 10 == 0 or i == total - 1:
|
|
171
|
+
ctx.progress(lo + (hi - lo) * ((i + 1) / max(1, total)))
|
|
172
|
+
ctx.log(f"DOI lookup: {i + 1}/{total} (found: {filled})")
|
|
173
|
+
|
|
174
|
+
return {"scanned": int(total), "filled": int(filled)}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def run_fill_all(ctx: JobContext, project_id: str) -> dict[str, Any]:
|
|
178
|
+
"""Birleşik 'Fill all missing' — YALNIZ API geçişi (DOI başına tek çağrı, tüm boş alanlar).
|
|
179
|
+
|
|
180
|
+
ML geçişi YOKTUR. df bir kez okunur, API ile yerinde doldurulur, aktif dataset'e yazılır.
|
|
181
|
+
İptal edilse bile o ana dek elde edilen API kazanımları korunur.
|
|
182
|
+
"""
|
|
183
|
+
src = _active_path(project_id)
|
|
184
|
+
df = await asyncio.to_thread(pd.read_excel, src)
|
|
185
|
+
ctx.log(f"Loaded: {len(df)} records")
|
|
186
|
+
snap = _snapshot(project_id, df, "fill_all")
|
|
187
|
+
ctx.log("Snapshot taken")
|
|
188
|
+
ctx.progress(0.05)
|
|
189
|
+
|
|
190
|
+
# Doluluk: işlem ÖNCESİ oranlar (rapor için)
|
|
191
|
+
track = [c for c in _RATE_FIELDS if c in df.columns]
|
|
192
|
+
before = {c: _fill_rate(df[c]) for c in track}
|
|
193
|
+
|
|
194
|
+
# 1) WC<->SC karşılıklı kopya — deterministik, DOI gerekmez, API'den ÖNCE (öncelikli).
|
|
195
|
+
mirrored = _mirror_wc_sc(df)
|
|
196
|
+
if mirrored:
|
|
197
|
+
ctx.log(f"WC<->SC mirror: {mirrored} field(s) filled")
|
|
198
|
+
|
|
199
|
+
# 2-3) Ters DOI bulma + API geçişi. df YERİNDE güncellenir; iptal (CancelledError
|
|
200
|
+
# veya ctx.cancelled) durumunda bile o ana dek elde edilen kazanımlar KORUNUR —
|
|
201
|
+
# geçişleri sarıp finally'de aktif veri kümesine yazıyoruz.
|
|
202
|
+
doi_stats = {"scanned": 0, "filled": 0}
|
|
203
|
+
api_stats = {"total": 0, "enriched": 0}
|
|
204
|
+
addr_stats = {"rows": 0, "addr": 0, "dois": 0}
|
|
205
|
+
cancelled = False
|
|
206
|
+
try:
|
|
207
|
+
doi_stats = await _doi_pass(ctx, df, lo=0.05, hi=0.4)
|
|
208
|
+
api_stats = await _api_pass(ctx, df, lo=0.4, hi=0.9)
|
|
209
|
+
# Adres tamamlama: kurumu olan ama ÜLKESİ eksik adreslere OpenAlex ülkesini
|
|
210
|
+
# deterministik ekle (API, LLM yok, ücretsiz — kullanıcı şeffaf, Fill'in parçası).
|
|
211
|
+
email = settings.crossref_email or settings.unpaywall_email or None
|
|
212
|
+
addr_stats = await _complete_addresses_pass(ctx, df, email, lo=0.9, hi=0.97)
|
|
213
|
+
except asyncio.CancelledError:
|
|
214
|
+
cancelled = True
|
|
215
|
+
finally:
|
|
216
|
+
_mirror_wc_sc(df) # API tek alanı doldurduysa WC/SC eşitle
|
|
217
|
+
await asyncio.to_thread(df.to_excel, src, index=False)
|
|
218
|
+
filter_engine._DF_CACHE.clear()
|
|
219
|
+
storage.touch_project(project_id)
|
|
220
|
+
if cancelled:
|
|
221
|
+
raise asyncio.CancelledError()
|
|
222
|
+
|
|
223
|
+
# Doluluk: işlem SONRASI oranlar + alan-bazlı öncesi/sonrası
|
|
224
|
+
after = {c: _fill_rate(df[c]) for c in track}
|
|
225
|
+
per_field_fill = {c: {"before": round(before[c], 4), "after": round(after[c], 4)} for c in track}
|
|
226
|
+
overall_before = round(sum(before.values()) / len(before), 4) if before else 0.0
|
|
227
|
+
overall_after = round(sum(after.values()) / len(after), 4) if after else 0.0
|
|
228
|
+
ctx.progress(1.0)
|
|
229
|
+
ctx.log(
|
|
230
|
+
f"Done — DOIs found: {doi_stats['filled']}; API: {api_stats['enriched']} records; "
|
|
231
|
+
f"addresses+country: {addr_stats['rows']}; "
|
|
232
|
+
f"fill {round(overall_before*100,1)}% -> {round(overall_after*100,1)}%"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"method": "fill_all",
|
|
237
|
+
# Hangi analize ait olduğu — rapor yalnız AKTİF analizde gösterilsin diye
|
|
238
|
+
# (yeni bir merge eski enrichment raporunu taşımamalı).
|
|
239
|
+
"analysis_id": analyses.get_active_analysis_id(project_id),
|
|
240
|
+
"total": int(len(df)),
|
|
241
|
+
"enriched": int(api_stats["enriched"]),
|
|
242
|
+
"api": api_stats,
|
|
243
|
+
"doi": doi_stats,
|
|
244
|
+
"addresses": addr_stats,
|
|
245
|
+
"fill_rate_before": overall_before,
|
|
246
|
+
"fill_rate_after": overall_after,
|
|
247
|
+
"per_field_fill": per_field_fill,
|
|
248
|
+
"snapshot": snap,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def _complete_addresses_pass(ctx: JobContext, df: pd.DataFrame, email: str | None,
|
|
253
|
+
lo: float = 0.95, hi: float = 0.99) -> dict[str, int]:
|
|
254
|
+
"""Adres tamamlama PASS'i (YALNIZ API, LLM YOK): kurumu olan ama ÜLKESİ eksik C1
|
|
255
|
+
adreslerine OpenAlex country_code'undan ülkeyi DETERMİNİSTİK ekler.
|
|
256
|
+
|
|
257
|
+
df YERİNDE güncellenir (kendi snapshot/yazımı YOK — run_fill_all halleder). Var
|
|
258
|
+
olan ülke EZİLMEZ, kurum/şehir korunur. Ayrım: parse_c1_address.country None ise
|
|
259
|
+
adres ülkesizdir → ekle; doluysa (örn. 'India') atla. Ülke kaynağı: kurum adı
|
|
260
|
+
OpenAlex kurumuyla eşleşirse o ülke, yoksa tek-ülke makalesinde o ülke."""
|
|
261
|
+
from bibex_core.modules.c1_utils import split_c1_addresses, parse_c1_address, append_country_to_c1
|
|
262
|
+
from services.disambiguation.orcid import fetch_affiliations_for_doi
|
|
263
|
+
from services.disambiguation.similarity import normalize_name
|
|
264
|
+
|
|
265
|
+
cols = [c for c in ("C1", "C1raw") if c in df.columns]
|
|
266
|
+
if not cols or "DI" not in df.columns:
|
|
267
|
+
return {"rows": 0, "addr": 0, "dois": 0}
|
|
268
|
+
main_col = "C1" if "C1" in df.columns else cols[0]
|
|
269
|
+
rows_changed = 0
|
|
270
|
+
addr_filled = 0
|
|
271
|
+
seen_doi: set[str] = set()
|
|
272
|
+
n = len(df)
|
|
273
|
+
for i, idx in enumerate(df.index):
|
|
274
|
+
if ctx.cancelled:
|
|
275
|
+
break
|
|
276
|
+
doi = df.at[idx, "DI"]
|
|
277
|
+
if doi is None or not str(doi).strip() or str(doi).strip().lower() == "nan":
|
|
278
|
+
continue
|
|
279
|
+
# Ülkesi eksik adres(ler): parse_c1_address.country None olanlar (son bileşen ülke değil).
|
|
280
|
+
tokens: list[str] = []
|
|
281
|
+
for addr in split_c1_addresses(df.at[idx, main_col]):
|
|
282
|
+
if parse_c1_address(addr)["country"] is None:
|
|
283
|
+
parts = [p.strip() for p in addr.split(",") if p.strip()]
|
|
284
|
+
if parts:
|
|
285
|
+
tokens.append(parts[-1])
|
|
286
|
+
if not tokens:
|
|
287
|
+
continue
|
|
288
|
+
d = str(doi).strip()
|
|
289
|
+
affs = await asyncio.to_thread(fetch_affiliations_for_doi, d, email)
|
|
290
|
+
seen_doi.add(d)
|
|
291
|
+
if not affs:
|
|
292
|
+
continue
|
|
293
|
+
distinct = {c for (_n, c) in affs if c}
|
|
294
|
+
single = next(iter(distinct)) if len(distinct) == 1 else None
|
|
295
|
+
append_map: dict[str, str] = {}
|
|
296
|
+
for tok in tokens:
|
|
297
|
+
ntok = normalize_name(tok)
|
|
298
|
+
matched = None
|
|
299
|
+
if ntok:
|
|
300
|
+
for (iname, ic) in affs:
|
|
301
|
+
if ic and normalize_name(iname) == ntok:
|
|
302
|
+
matched = ic
|
|
303
|
+
break
|
|
304
|
+
country = matched or single
|
|
305
|
+
if country:
|
|
306
|
+
append_map[tok.lower()] = country
|
|
307
|
+
if not append_map:
|
|
308
|
+
continue
|
|
309
|
+
changed = False
|
|
310
|
+
for col in cols:
|
|
311
|
+
old = "" if pd.isna(df.at[idx, col]) else str(df.at[idx, col])
|
|
312
|
+
new = append_country_to_c1(old, append_map)
|
|
313
|
+
if new != old:
|
|
314
|
+
df.at[idx, col] = new
|
|
315
|
+
changed = True
|
|
316
|
+
if changed:
|
|
317
|
+
rows_changed += 1
|
|
318
|
+
addr_filled += len(append_map)
|
|
319
|
+
if (i % 25) == 0:
|
|
320
|
+
ctx.progress(min(hi, lo + (hi - lo) * (i / max(1, n))))
|
|
321
|
+
return {"rows": rows_changed, "addr": addr_filled, "dois": len(seen_doi)}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ── Geriye uyumluluk: ayrı API endpoint'i (UI artık run_fill_all kullanır; ML yoktur) ──
|
|
325
|
+
|
|
326
|
+
async def run_api_enrichment(ctx: JobContext, project_id: str, sources: list[str] | None = None) -> dict[str, Any]:
|
|
327
|
+
"""Yalnız API geçişi — aktif dataset'e yazar."""
|
|
328
|
+
src = _active_path(project_id)
|
|
329
|
+
df = await asyncio.to_thread(pd.read_excel, src)
|
|
330
|
+
ctx.log(f"Loaded: {len(df)} records")
|
|
331
|
+
snap = _snapshot(project_id, df, "api")
|
|
332
|
+
ctx.progress(0.05)
|
|
333
|
+
stats = await _api_pass(ctx, df, lo=0.05, hi=0.95)
|
|
334
|
+
await asyncio.to_thread(df.to_excel, src, index=False)
|
|
335
|
+
filter_engine._DF_CACHE.clear()
|
|
336
|
+
storage.touch_project(project_id)
|
|
337
|
+
ctx.progress(1.0)
|
|
338
|
+
return {"method": "api", "total": stats["total"], "enriched": stats["enriched"], "snapshot": snap}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Filtre uygulanmış/uygulanmamış birleştirilmiş veriyi farklı formatlarda dışa aktar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from fastapi import HTTPException
|
|
11
|
+
|
|
12
|
+
from services import filter_engine, storage
|
|
13
|
+
from services.bibex_adapter import _suppress_stdio
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
VALID_FORMATS = {"wos", "vos", "bib", "ris", "csv", "xlsx", "tsv"}
|
|
17
|
+
|
|
18
|
+
# Format anahtarı → gerçek dosya uzantısı (anahtardan farklı olanlar). WoS plain-text
|
|
19
|
+
# ve VOSviewer tab-text çıktıları .txt'dir (.wos/.vos standart değil; WoS = savedrecs.txt).
|
|
20
|
+
_EXT = {"wos": "txt", "vos": "txt"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _project_paths(project_id: str) -> tuple[Path, Path]:
|
|
24
|
+
meta = storage.get_project(project_id)
|
|
25
|
+
if meta is None:
|
|
26
|
+
raise HTTPException(404, "Proje bulunamadı")
|
|
27
|
+
root = storage.project_dir(project_id)
|
|
28
|
+
exports = root / "exports"
|
|
29
|
+
exports.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
return root, exports
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_filtered(project_id: str, spec: Optional[dict[str, Any]]) -> pd.DataFrame:
|
|
34
|
+
df = filter_engine.load_merged(project_id)
|
|
35
|
+
if spec:
|
|
36
|
+
df = filter_engine.apply_filter(df, spec)
|
|
37
|
+
return df
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def export(
|
|
41
|
+
project_id: str,
|
|
42
|
+
fmt: str,
|
|
43
|
+
filter_spec: Optional[dict[str, Any]] = None,
|
|
44
|
+
output_name: Optional[str] = None,
|
|
45
|
+
) -> Path:
|
|
46
|
+
if fmt not in VALID_FORMATS:
|
|
47
|
+
raise HTTPException(400, f"Desteklenmeyen format: {fmt}")
|
|
48
|
+
_, exports = _project_paths(project_id)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
df = _load_filtered(project_id, filter_spec)
|
|
52
|
+
except FileNotFoundError as e:
|
|
53
|
+
raise HTTPException(409, str(e))
|
|
54
|
+
|
|
55
|
+
if len(df) == 0:
|
|
56
|
+
raise HTTPException(400, "Filtre 0 kayıt döndürdü — export yapılmadı")
|
|
57
|
+
|
|
58
|
+
stamp = time.strftime("%Y%m%d_%H%M%S")
|
|
59
|
+
ext = _EXT.get(fmt, fmt)
|
|
60
|
+
if output_name:
|
|
61
|
+
name = output_name
|
|
62
|
+
if not name.lower().endswith(f".{ext}"):
|
|
63
|
+
name = f"{name}.{ext}"
|
|
64
|
+
else:
|
|
65
|
+
# wos/vos gibi anahtar≠uzantı durumunda formatı isimde tut: export_..._wos.txt
|
|
66
|
+
name = f"export_{stamp}_{fmt}.{ext}" if ext != fmt else f"export_{stamp}.{ext}"
|
|
67
|
+
output = exports / Path(name).name
|
|
68
|
+
|
|
69
|
+
if fmt == "xlsx":
|
|
70
|
+
df.to_excel(output, index=False)
|
|
71
|
+
elif fmt == "csv":
|
|
72
|
+
df.to_csv(output, index=False, encoding="utf-8")
|
|
73
|
+
elif fmt == "tsv":
|
|
74
|
+
df.to_csv(output, sep="\t", index=False, encoding="utf-8")
|
|
75
|
+
elif fmt == "wos":
|
|
76
|
+
# Geçici XLSX üzerinden bibex_core.xlsx2vos
|
|
77
|
+
from bibex_core.xlsx2vos import convert_excel_to_wos
|
|
78
|
+
tmp_xlsx = exports / f"_tmp_{stamp}.xlsx"
|
|
79
|
+
df.to_excel(tmp_xlsx, index=False)
|
|
80
|
+
with _suppress_stdio():
|
|
81
|
+
convert_excel_to_wos(str(tmp_xlsx), str(output))
|
|
82
|
+
tmp_xlsx.unlink(missing_ok=True)
|
|
83
|
+
elif fmt == "vos":
|
|
84
|
+
# VOSviewer için tab-separated (bibliometrix uyumlu temel kolonlar)
|
|
85
|
+
cols = [c for c in ("AU", "TI", "SO", "PY", "VL", "IS", "PG", "DI", "DE", "ID", "AB", "TC", "DT", "DB", "WC", "SC")
|
|
86
|
+
if c in df.columns]
|
|
87
|
+
df[cols].to_csv(output, sep="\t", index=False, encoding="utf-8")
|
|
88
|
+
elif fmt == "bib":
|
|
89
|
+
from services.bibtex_writer import write_bibtex
|
|
90
|
+
write_bibtex(df, output)
|
|
91
|
+
elif fmt == "ris":
|
|
92
|
+
from services.ris_writer import write_ris
|
|
93
|
+
write_ris(df, output)
|
|
94
|
+
else:
|
|
95
|
+
raise HTTPException(500, "İç hata")
|
|
96
|
+
|
|
97
|
+
storage.touch_project(project_id)
|
|
98
|
+
return output
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def list_exports(project_id: str) -> list[dict]:
|
|
102
|
+
_, exports = _project_paths(project_id)
|
|
103
|
+
out = []
|
|
104
|
+
for f in sorted(exports.iterdir(), reverse=True):
|
|
105
|
+
if not f.is_file():
|
|
106
|
+
continue
|
|
107
|
+
out.append({
|
|
108
|
+
"name": f.name,
|
|
109
|
+
"size": f.stat().st_size,
|
|
110
|
+
"relative_path": str(f.relative_to(storage.settings.storage_path)),
|
|
111
|
+
})
|
|
112
|
+
return out
|