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.
Files changed (171) hide show
  1. bibex_core/MergeDB.py +1189 -0
  2. bibex_core/__init__.py +13 -0
  3. bibex_core/modules/__init__.py +35 -0
  4. bibex_core/modules/api_utils.py +1172 -0
  5. bibex_core/modules/c1_utils.py +423 -0
  6. bibex_core/modules/file_utils.py +71 -0
  7. bibex_core/modules/ml_utils.py +332 -0
  8. bibex_core/modules/post_process.py +50 -0
  9. bibex_core/modules/stats_utils.py +216 -0
  10. bibex_core/scp2xlsx.py +235 -0
  11. bibex_core/wos2xlsx.py +159 -0
  12. bibex_core/xlsx2vos.py +185 -0
  13. bibexpy/__init__.py +9 -0
  14. bibexpy/__main__.py +6 -0
  15. bibexpy/_server/__init__.py +1 -0
  16. bibexpy/_server/config.py +92 -0
  17. bibexpy/_server/jobs/__init__.py +3 -0
  18. bibexpy/_server/jobs/runner.py +301 -0
  19. bibexpy/_server/main.py +211 -0
  20. bibexpy/_server/models/__init__.py +0 -0
  21. bibexpy/_server/models/project.py +25 -0
  22. bibexpy/_server/routers/__init__.py +0 -0
  23. bibexpy/_server/routers/audit.py +65 -0
  24. bibexpy/_server/routers/convert.py +101 -0
  25. bibexpy/_server/routers/disambiguate.py +177 -0
  26. bibexpy/_server/routers/downloads.py +26 -0
  27. bibexpy/_server/routers/enrich.py +42 -0
  28. bibexpy/_server/routers/export.py +116 -0
  29. bibexpy/_server/routers/export_folder.py +97 -0
  30. bibexpy/_server/routers/filter.py +131 -0
  31. bibexpy/_server/routers/jobs.py +72 -0
  32. bibexpy/_server/routers/merge.py +437 -0
  33. bibexpy/_server/routers/prepare.py +241 -0
  34. bibexpy/_server/routers/projects.py +49 -0
  35. bibexpy/_server/routers/quality.py +288 -0
  36. bibexpy/_server/routers/records.py +204 -0
  37. bibexpy/_server/routers/report.py +111 -0
  38. bibexpy/_server/routers/settings.py +383 -0
  39. bibexpy/_server/routers/system.py +209 -0
  40. bibexpy/_server/routers/tools.py +257 -0
  41. bibexpy/_server/routers/upload.py +110 -0
  42. bibexpy/_server/services/__init__.py +0 -0
  43. bibexpy/_server/services/analyses.py +364 -0
  44. bibexpy/_server/services/audit.py +340 -0
  45. bibexpy/_server/services/bibex_adapter.py +47 -0
  46. bibexpy/_server/services/bibtex_writer.py +104 -0
  47. bibexpy/_server/services/converter.py +205 -0
  48. bibexpy/_server/services/disambiguation/__init__.py +19 -0
  49. bibexpy/_server/services/disambiguation/blocking.py +581 -0
  50. bibexpy/_server/services/disambiguation/cache.py +47 -0
  51. bibexpy/_server/services/disambiguation/deepseek_client.py +203 -0
  52. bibexpy/_server/services/disambiguation/orcid.py +261 -0
  53. bibexpy/_server/services/disambiguation/pipeline.py +985 -0
  54. bibexpy/_server/services/disambiguation/similarity.py +91 -0
  55. bibexpy/_server/services/enricher.py +338 -0
  56. bibexpy/_server/services/exporter.py +112 -0
  57. bibexpy/_server/services/filter_engine.py +300 -0
  58. bibexpy/_server/services/merger.py +202 -0
  59. bibexpy/_server/services/methodology.py +102 -0
  60. bibexpy/_server/services/report_export.py +300 -0
  61. bibexpy/_server/services/ris_writer.py +62 -0
  62. bibexpy/_server/services/smart_merger.py +967 -0
  63. bibexpy/_server/services/storage.py +165 -0
  64. bibexpy/_web/404/index.html +1 -0
  65. bibexpy/_web/404.html +1 -0
  66. bibexpy/_web/_next/static/chunks/117-7ad5186126fa076d.js +2 -0
  67. bibexpy/_web/_next/static/chunks/156-75beffb8ae92a7f3.js +1 -0
  68. bibexpy/_web/_next/static/chunks/169-301c04fcec22686f.js +4 -0
  69. bibexpy/_web/_next/static/chunks/2-ecc1c36a5632ac61.js +1 -0
  70. bibexpy/_web/_next/static/chunks/244-be343e0f8aa00fb5.js +1 -0
  71. bibexpy/_web/_next/static/chunks/367-b7457c2b95f6932b.js +1 -0
  72. bibexpy/_web/_next/static/chunks/382-9cd163eed632e241.js +1 -0
  73. bibexpy/_web/_next/static/chunks/392-afa8bfa4c1a9893d.js +1 -0
  74. bibexpy/_web/_next/static/chunks/507-63607ded9f83c1aa.js +1 -0
  75. bibexpy/_web/_next/static/chunks/624-cae565632f47217d.js +1 -0
  76. bibexpy/_web/_next/static/chunks/739-a00c6b9c0f11813e.js +1 -0
  77. bibexpy/_web/_next/static/chunks/862-8e1bd5f93c082c02.js +1 -0
  78. bibexpy/_web/_next/static/chunks/app/_not-found/page-873ba59b5eb7c342.js +1 -0
  79. bibexpy/_web/_next/static/chunks/app/layout-0d5fc68ac8971a04.js +1 -0
  80. bibexpy/_web/_next/static/chunks/app/page-cf2bea5edc74459a.js +1 -0
  81. bibexpy/_web/_next/static/chunks/app/projects/[id]/convert/page-ab6dd645ae113b64.js +1 -0
  82. bibexpy/_web/_next/static/chunks/app/projects/[id]/disambiguate/page-dd98482cc47344e9.js +1 -0
  83. bibexpy/_web/_next/static/chunks/app/projects/[id]/enrich/page-4727e431468514e9.js +1 -0
  84. bibexpy/_web/_next/static/chunks/app/projects/[id]/export/page-f9005d164bb22cb0.js +1 -0
  85. bibexpy/_web/_next/static/chunks/app/projects/[id]/layout-07755fda55b3ae1f.js +1 -0
  86. bibexpy/_web/_next/static/chunks/app/projects/[id]/merge/page-45b95318ea7beccf.js +1 -0
  87. bibexpy/_web/_next/static/chunks/app/projects/[id]/records/page-bfad80457da9eb0b.js +1 -0
  88. bibexpy/_web/_next/static/chunks/app/projects/[id]/report/page-f05767e74158e995.js +1 -0
  89. bibexpy/_web/_next/static/chunks/app/projects/[id]/upload/page-8df78ca9cdecbcb8.js +1 -0
  90. bibexpy/_web/_next/static/chunks/app/projects/page-99960711ac6a13e9.js +1 -0
  91. bibexpy/_web/_next/static/chunks/app/settings/page-002df809d6c5a227.js +1 -0
  92. bibexpy/_web/_next/static/chunks/app/tools/page-27eee1c6a590859f.js +1 -0
  93. bibexpy/_web/_next/static/chunks/fd9d1056-f2718be92181f426.js +1 -0
  94. bibexpy/_web/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
  95. bibexpy/_web/_next/static/chunks/main-app-d138a6e2f8c847f6.js +1 -0
  96. bibexpy/_web/_next/static/chunks/main-c4b9ddbf514503ef.js +1 -0
  97. bibexpy/_web/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
  98. bibexpy/_web/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
  99. bibexpy/_web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  100. bibexpy/_web/_next/static/chunks/webpack-c81f7fd28659d64f.js +1 -0
  101. bibexpy/_web/_next/static/css/1c4638f3e39cf127.css +3 -0
  102. bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_buildManifest.js +1 -0
  103. bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_ssgManifest.js +1 -0
  104. bibexpy/_web/images/PROMPTS.md +83 -0
  105. bibexpy/_web/images/about-illustration.png +0 -0
  106. bibexpy/_web/images/authors/alperen-sahin.jpg +0 -0
  107. bibexpy/_web/images/authors/burak-can-kara.jpg +0 -0
  108. bibexpy/_web/images/authors/taskin-dirsehan.jpg +0 -0
  109. bibexpy/_web/images/bibexpy-logo-full.png +0 -0
  110. bibexpy/_web/images/bibexpy-logo-header.png +0 -0
  111. bibexpy/_web/images/bibexpy-logo-in.png +0 -0
  112. bibexpy/_web/images/bibexpy-logo.webp +0 -0
  113. bibexpy/_web/images/hero-illustration.png +0 -0
  114. bibexpy/_web/images/loader.gif +0 -0
  115. bibexpy/_web/images/workflow-diagram.png +0 -0
  116. bibexpy/_web/index.html +1 -0
  117. bibexpy/_web/index.txt +11 -0
  118. bibexpy/_web/projects/_/convert/index.html +1 -0
  119. bibexpy/_web/projects/_/convert/index.txt +12 -0
  120. bibexpy/_web/projects/_/disambiguate/index.html +1 -0
  121. bibexpy/_web/projects/_/disambiguate/index.txt +12 -0
  122. bibexpy/_web/projects/_/enrich/index.html +1 -0
  123. bibexpy/_web/projects/_/enrich/index.txt +12 -0
  124. bibexpy/_web/projects/_/export/index.html +1 -0
  125. bibexpy/_web/projects/_/export/index.txt +12 -0
  126. bibexpy/_web/projects/_/merge/index.html +1 -0
  127. bibexpy/_web/projects/_/merge/index.txt +12 -0
  128. bibexpy/_web/projects/_/records/index.html +1 -0
  129. bibexpy/_web/projects/_/records/index.txt +12 -0
  130. bibexpy/_web/projects/_/report/index.html +1 -0
  131. bibexpy/_web/projects/_/report/index.txt +12 -0
  132. bibexpy/_web/projects/_/upload/index.html +1 -0
  133. bibexpy/_web/projects/_/upload/index.txt +12 -0
  134. bibexpy/_web/projects/index.html +1 -0
  135. bibexpy/_web/projects/index.txt +11 -0
  136. bibexpy/_web/settings/index.html +1 -0
  137. bibexpy/_web/settings/index.txt +11 -0
  138. bibexpy/_web/tools/README.md +65 -0
  139. bibexpy/_web/tools/bibexcel.svg +6 -0
  140. bibexpy/_web/tools/bibliometrix.png +0 -0
  141. bibexpy/_web/tools/biblioshiny.svg +12 -0
  142. bibexpy/_web/tools/citavi.svg +6 -0
  143. bibexpy/_web/tools/citespace.svg +7 -0
  144. bibexpy/_web/tools/citnetexplorer.svg +8 -0
  145. bibexpy/_web/tools/endnote.svg +6 -0
  146. bibexpy/_web/tools/excel.svg +1 -0
  147. bibexpy/_web/tools/gephi.svg +66 -0
  148. bibexpy/_web/tools/histcite.svg +7 -0
  149. bibexpy/_web/tools/index.html +1 -0
  150. bibexpy/_web/tools/index.txt +11 -0
  151. bibexpy/_web/tools/jabref.svg +122 -0
  152. bibexpy/_web/tools/latex.svg +1 -0
  153. bibexpy/_web/tools/mendeley.svg +1 -0
  154. bibexpy/_web/tools/openrefine.svg +15 -0
  155. bibexpy/_web/tools/overleaf.svg +1 -0
  156. bibexpy/_web/tools/papers.svg +7 -0
  157. bibexpy/_web/tools/powerbi.svg +1 -0
  158. bibexpy/_web/tools/python.svg +1 -0
  159. bibexpy/_web/tools/r.svg +1 -0
  160. bibexpy/_web/tools/refworks.svg +5 -0
  161. bibexpy/_web/tools/scite.svg +5 -0
  162. bibexpy/_web/tools/tableau.svg +1 -0
  163. bibexpy/_web/tools/vosviewer.png +0 -0
  164. bibexpy/_web/tools/zotero.svg +1 -0
  165. bibexpy/cli.py +242 -0
  166. bibexpy-2.0.0.dist-info/METADATA +105 -0
  167. bibexpy-2.0.0.dist-info/RECORD +171 -0
  168. bibexpy-2.0.0.dist-info/WHEEL +5 -0
  169. bibexpy-2.0.0.dist-info/entry_points.txt +2 -0
  170. bibexpy-2.0.0.dist-info/licenses/LICENSE +674 -0
  171. 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