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,581 @@
1
+ """Deterministik aday-grup üretimi (LLM çağrısından önce).
2
+
3
+ Yazar blocking: aynı soyadı + ilk harf (ilk ortak token bazlı)
4
+ Affiliation blocking: ilk anlamlı token + benzerlik
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections import defaultdict
11
+ from typing import Callable, Iterable, Optional
12
+
13
+ import pandas as pd
14
+
15
+ from .similarity import jaro_winkler, name_initials, normalize_name
16
+
17
+
18
+ # Kooperatif iptal: ağır builder'lar bir thread'de (asyncio.to_thread) çalışır.
19
+ # task.cancel() thread'i ÖLDÜREMEZ — bu yüzden builder, döngü içinde periyodik
20
+ # olarak `should_cancel()` kontrol eder ve iptalde erken çıkar. Böylece orphan
21
+ # thread paylaşılan havuzu (ve uygulamayı) kilitlemez. _CANCEL_EVERY: kontrol
22
+ # sıklığı (her N satır/blok), çağrı maliyetini ihmal edilebilir tutar.
23
+ CancelCheck = Optional[Callable[[], bool]]
24
+ _CANCEL_EVERY = 256
25
+
26
+
27
+ def _is_cancelled(should_cancel: CancelCheck, counter: int) -> bool:
28
+ return bool(should_cancel) and (counter % _CANCEL_EVERY == 0) and should_cancel()
29
+
30
+
31
+ def _split_authors(value: str) -> list[str]:
32
+ return [a.strip() for a in re.split(r";|\band\b", str(value or "")) if a.strip()]
33
+
34
+
35
+ # Ayrıştırma (split) eki: sistemin kendi koyduğu "(b)", "(c)" ... veya "(7)" işareti.
36
+ # Bu ek, daha önce FARKLI kişi olarak ayrılmış bir yazarı belirtir — birleştirme
37
+ # adayı olarak görülmemelidir (yoksa scan, kendi split'ini geri alır). Hem tek harf
38
+ # (b-z) hem de sayısal (build_author_splits'in 8+ grupta ürettiği "(7)" gibi) ekleri kapsar.
39
+ _SPLIT_SUFFIX_RE = re.compile(r"\s*\((?:[b-z]|\d+)\)\s*$", re.IGNORECASE)
40
+
41
+
42
+ def _split_suffix(name: str) -> str:
43
+ """İsimdeki ayrıştırma ekini döndür ('b', 'c', '7' ... ) ya da boş ('' = düz/asıl)."""
44
+ m = _SPLIT_SUFFIX_RE.search(str(name or ""))
45
+ return m.group(0).strip().strip("()").lower() if m else ""
46
+
47
+
48
+ def _base_name(name: str) -> str:
49
+ """Ayrıştırma eki olmadan normalize isim ('LIU L (b)' -> 'liu l').
50
+
51
+ Kümeleme/benzerlik için kullanılan KANONİK norm budur: '(b)' eki norm'a
52
+ karışmaz, böylece 'LIU L (b)' yapay olarak 'LIU LEI'ye benzemez (cross-base
53
+ sızıntısı önlenir) ve gerçek kök isimle aynı bloğa düşer.
54
+ """
55
+ return normalize_name(_SPLIT_SUFFIX_RE.sub("", str(name or "")))
56
+
57
+
58
+ def _split_affs(value: str) -> list[str]:
59
+ parts = []
60
+ for chunk in str(value or "").split(";"):
61
+ chunk = re.sub(r"\[.*?\]", "", chunk).strip()
62
+ if chunk:
63
+ parts.append(chunk)
64
+ return parts
65
+
66
+
67
+ # ───────────── Tier sınıflandırma yardımcıları ─────────────
68
+ # 3 katmanlı güven (Fellegi-Sunter 1969 match / possible-match / non-match):
69
+ # Tier 1 (auto) — çok yüksek benzerlik + paylaşılan bağlam → LLM'siz birleştir
70
+ # Tier 2 (llm) — orta benzerlik / belirsiz → LLM hakemliğine gönder
71
+ # Tier 3 (skip) — tek varyant / uzak → dokunma
72
+
73
+ # Alan/kategori kolonları — kurumdan daha kararlı sinyal (kişi kurum değiştirir
74
+ # ama alanını nadiren değiştirir). WC: WoS kategorileri, SC: araştırma alanları,
75
+ # DE: yazar anahtar kelimeleri, ID: Keywords Plus.
76
+ _FIELD_COLS = ("WC", "SC", "DE", "ID")
77
+
78
+
79
+ def _row_fields(row) -> set[str]:
80
+ """Bir kaydın alan/kategori/anahtar-kelime token kümesi (normalize).
81
+ pandas NaN / boş / 'nan' değerleri sahte token üretmesin diye elenir.
82
+ """
83
+ out: set[str] = set()
84
+ for col in _FIELD_COLS:
85
+ val = row.get(col, "")
86
+ if val is None:
87
+ continue
88
+ sval = str(val).strip()
89
+ if not sval or sval.lower() == "nan":
90
+ continue
91
+ for tok in sval.split(";"):
92
+ t = tok.strip().lower()
93
+ if t and t != "nan":
94
+ out.add(t)
95
+ return out
96
+
97
+
98
+ def _field_signal(members: list[dict]) -> bool | None:
99
+ """Alan sinyali (kurum yerine konu/kategori bazlı):
100
+ None → hiç alan verisi yok → sinyal yok, isim benzerliğine güven
101
+ True → en az iki mention ortak alan/kategori paylaşıyor → aynı kişi lehine
102
+ False → alan verisi var ama örtüşme yok → farklı alanlar, aynı-isim/farklı-kişi şüphesi
103
+ """
104
+ sets = [m.get("fields") or set() for m in members]
105
+ if not any(sets):
106
+ return None
107
+ n = len(members)
108
+ for i in range(n):
109
+ for j in range(i + 1, n):
110
+ if sets[i] & sets[j]:
111
+ return True
112
+ return False
113
+
114
+
115
+ def _greedy_cluster(members: list[dict], threshold: float) -> list[list[dict]]:
116
+ """Mention'ları norm benzerliğine göre transitif öbekle (≥ threshold → aynı küme).
117
+
118
+ İstisna: split eki ('(b)', '(c)' ...) TAŞIYAN bir mention, sistemin daha önce
119
+ bilerek ayırdığı bir kimliktir → hiçbir birleştirme kümesine sokulmaz (tek başına
120
+ kalır, sonra <2 varyant filtresiyle elenir). Aksi halde scan kendi split'ini geri
121
+ alır (örn. 'LIU L (b)'yi 'LIU LEI' ile birleştirmeye çalışır).
122
+ """
123
+ clustered: list[list[dict]] = []
124
+ for m in members:
125
+ if m.get("suffix"):
126
+ clustered.append([m]) # ayrıştırılmış kimlik → izole, asla birleşme adayı
127
+ continue
128
+ placed = False
129
+ for cluster in clustered:
130
+ # Hedef küme bir split-kimliği içeriyorsa oraya da koyma.
131
+ if any(x.get("suffix") for x in cluster):
132
+ continue
133
+ if any(jaro_winkler(m["norm"], x["norm"]) >= threshold for x in cluster):
134
+ cluster.append(m)
135
+ placed = True
136
+ break
137
+ if not placed:
138
+ clustered.append([m])
139
+ return clustered
140
+
141
+
142
+ def _min_pairwise_jw(norms: set[str]) -> float:
143
+ """Bir varyant kümesindeki en düşük ikili Jaro-Winkler (küme tutarlılığı ölçüsü)."""
144
+ items = sorted(norms)
145
+ if len(items) < 2:
146
+ return 1.0
147
+ lo = 1.0
148
+ for i in range(len(items)):
149
+ for j in range(i + 1, len(items)):
150
+ lo = min(lo, jaro_winkler(items[i], items[j]))
151
+ return lo
152
+
153
+
154
+ def _candidate(key: str, ci: int, cluster: list[dict], variant_field: str, tier: int) -> dict:
155
+ """Bir öbeği UI/LLM şemasına uygun aday objesine dönüştür."""
156
+ names = sorted({m["name"] for m in cluster})
157
+ cand = {
158
+ "id": f"{key}_{ci}",
159
+ variant_field: names,
160
+ "affiliations": sorted({a for m in cluster for a in m.get("affiliations", [])})[:10],
161
+ "coauthors": sorted({c for m in cluster for c in m.get("coauthors", [])})[:10],
162
+ "records": sorted({m["record"] for m in cluster})[:50],
163
+ "tier": tier,
164
+ }
165
+ years = sorted({m["year"] for m in cluster if m.get("year")})
166
+ cand["year_range"] = [years[0], years[-1]] if years else []
167
+ return cand
168
+
169
+
170
+ def build_author_blocks(
171
+ df: pd.DataFrame,
172
+ sim_threshold: float = 0.85,
173
+ mode: str = "auto",
174
+ auto_threshold: float = 0.95,
175
+ should_cancel: CancelCheck = None,
176
+ ) -> list[dict]:
177
+ """Yazar adlarını blok blok grupla; saf eşik mantığıyla tier'lara ayır.
178
+
179
+ 3 katman (Fellegi-Sunter match / possible-match / non-match):
180
+ • Tier 1 (auto) — isim benzerliği ≥ auto_threshold (0.95) VE alan çelişkisi yok
181
+ → LLM'siz birleştir önerisi (block["auto"])
182
+ • Tier 2 (llm) — 0.75–0.95 arası, ya da ≥0.95 ama alanlar farklı
183
+ → LLM hakemliğine (block["members"])
184
+ • Tier 3 (skip) — tek varyant / < sim_threshold → dokunma
185
+
186
+ Alan sinyali: WC/SC/DE/ID (konu/kategori). Kurumdan daha kararlı çünkü kişi
187
+ kurum değiştirir ama alanını nadiren değiştirir. `mode` parametresi geriye
188
+ uyumluluk için durur, davranışı etkilemez.
189
+
190
+ Dönüş: bloklar; her blokta opsiyonel "auto" (Tier 1) ve "members" (Tier 2).
191
+ """
192
+ if "AU" not in df.columns:
193
+ return []
194
+
195
+ # surname+initial -> list of (record_idx, name, affiliations, coauthors, year)
196
+ blocks: dict[str, list[dict]] = defaultdict(list)
197
+ for _ri, (idx, row) in enumerate(df.iterrows()):
198
+ if _is_cancelled(should_cancel, _ri):
199
+ return []
200
+ authors = _split_authors(row.get("AU", ""))
201
+ if not authors:
202
+ continue
203
+ affs = _split_affs(row.get("C1", "") or row.get("C1raw", ""))
204
+ coauthors = authors
205
+ year = str(row.get("PY", "") or "").strip()
206
+ fields = _row_fields(row) # WC/SC/DE/ID — alan sinyali (kurumdan kararlı)
207
+ for au in authors:
208
+ # Blok anahtarı + benzerlik, split eki ('(b)') OLMADAN hesaplanır; aksi
209
+ # halde '(b)' hem initials'ı ('l b') hem norm'u kirletip yapay benzerlik
210
+ # üretir ve cross-base sızıntısına yol açar.
211
+ base_au = _SPLIT_SUFFIX_RE.sub("", au).strip()
212
+ surname, initials = name_initials(base_au)
213
+ if not surname:
214
+ continue
215
+ key = f"{surname}_{initials[:1]}" if initials else surname
216
+ blocks[key].append({
217
+ "record": int(idx),
218
+ "name": au,
219
+ "norm": normalize_name(base_au), # suffix'siz kanonik norm
220
+ "base": _base_name(au), # = norm; çatışma tespiti için açıkça
221
+ "suffix": _split_suffix(au), # '' / 'b' / 'c' / '7' ...
222
+ "affiliations": affs,
223
+ "coauthors": [c for c in coauthors if c != au],
224
+ "year": year,
225
+ "fields": fields,
226
+ })
227
+
228
+ # Her blok: sim_threshold (0.75) bileşenleri → saf eşik mantığı
229
+ # bileşen ≥2 varyant + min ikili JW ≥ auto_threshold (0.95) → Tier 1 (otomatik)
230
+ # bileşen ≥2 varyant + 0.75–0.95 arası → Tier 2 (LLM)
231
+ # tek varyant / <0.75 → Tier 3 (atla)
232
+ out: list[dict] = []
233
+ for key, members in blocks.items():
234
+ # Blok döngüsü: bloklar az ama her biri ağır (_greedy_cluster O(n²)) →
235
+ # her blokta kontrol et (modulo'suz); iptalde o ana kadarki sonucu döndür.
236
+ if should_cancel and should_cancel():
237
+ return out
238
+ if len({m["norm"] for m in members}) < 2:
239
+ continue # birleştirilecek varyant yok
240
+
241
+ auto_clusters: list[dict] = []
242
+ llm_members: list[dict] = []
243
+ for ci, comp in enumerate(_greedy_cluster(members, sim_threshold)):
244
+ comp_norms = {m["norm"] for m in comp}
245
+ if len(comp_norms) < 2:
246
+ continue # tek varyantlı bileşen → birleştirme yok (Tier 3)
247
+ if _min_pairwise_jw(comp_norms) >= auto_threshold and _field_signal(comp) is not False:
248
+ # İsimler ≥0.95 VE (ortak alan VAR ya da alan verisi YOK) → otomatik (Tier 1).
249
+ # Alan verisi var ama örtüşmüyorsa (_field_signal False) → AI'a düşer.
250
+ auto_clusters.append(_candidate(key, ci, comp, "name_variants", tier=1))
251
+ else:
252
+ llm_members.append(_candidate(key, ci, comp, "name_variants", tier=2))
253
+
254
+ block: dict = {"key": key}
255
+ if auto_clusters:
256
+ block["auto"] = auto_clusters
257
+ if llm_members:
258
+ block["members"] = llm_members
259
+ if block.get("auto") or block.get("members"):
260
+ out.append(block)
261
+
262
+ return out
263
+
264
+
265
+ def _field_components(recs: list[dict]) -> list[list[dict]]:
266
+ """Kayıtları 'ortak kategori paylaşıyorlar' bağına göre bileşenlere ayır.
267
+ Bir kayıt birden çok bileşene değiyorsa o bileşenler birleşir (transitif).
268
+ Sonuç bileşenler birbirinden alan bakımından TAM AYRIK olur.
269
+ """
270
+ comps: list[list[dict]] = []
271
+ for r in recs:
272
+ rf = r["fields"]
273
+ hit = [ci for ci, comp in enumerate(comps) if any(rf & x["fields"] for x in comp)]
274
+ if not hit:
275
+ comps.append([r])
276
+ else:
277
+ first = hit[0]
278
+ comps[first].append(r)
279
+ for ci in sorted(hit[1:], reverse=True):
280
+ comps[first].extend(comps[ci])
281
+ del comps[ci]
282
+ return comps
283
+
284
+
285
+ def build_author_splits(df: pd.DataFrame, should_cancel: CancelCheck = None) -> list[dict]:
286
+ """Yazar AYRIŞTIRMA: aynı yazılışa sahip bir isim, alan bakımından ayrık
287
+ kayıt gruplarına bölünüyorsa muhtemelen FARKLI kişilerdir → ayrıştırma önerisi.
288
+
289
+ Tier 1 (net): tüm kayıtların alan verisi var ve 2+ tam-ayrık gruba bölünüyor
290
+ → "şu alanlar şuradan ayrı" gerekçesiyle ayrıştır (en büyük grup
291
+ düz kalır, diğerleri (b) (c)... eki alır).
292
+ Tier 2 (sınırda): bazı kayıtlarda alan verisi yok → AI'a sor (ayır / bırak).
293
+ Aksi (tek grup / zincirlenmiş ortak alan) → ayrıştırma yok.
294
+
295
+ Dönüş: split önerileri; her biri {split_id, name, norm, tier, groups[], reason}.
296
+ """
297
+ if "AU" not in df.columns:
298
+ return []
299
+ mentions: dict[str, list[dict]] = defaultdict(list)
300
+ name_of: dict[str, str] = {}
301
+ for _ri, (idx, row) in enumerate(df.iterrows()):
302
+ if _is_cancelled(should_cancel, _ri):
303
+ return []
304
+ authors = _split_authors(row.get("AU", ""))
305
+ if not authors:
306
+ continue
307
+ fields = _row_fields(row)
308
+ for au in authors:
309
+ # Zaten ayrıştırılmış isim ('ZIPF A (b)') TEKRAR split adayı olmamalı —
310
+ # yoksa her scan onu yeniden bölüp 'ZIPF A (b) (b)' üretir (idempotent değil).
311
+ if _split_suffix(au):
312
+ continue
313
+ nm = normalize_name(au)
314
+ if not nm:
315
+ continue
316
+ mentions[nm].append({"record": int(idx), "fields": fields})
317
+ name_of.setdefault(nm, au)
318
+
319
+ suffixes = ["", "(b)", "(c)", "(d)", "(e)", "(f)", "(g)"]
320
+ out: list[dict] = []
321
+ for norm, recs in mentions.items():
322
+ if should_cancel and should_cancel():
323
+ return out
324
+ if len(recs) < 2:
325
+ continue # tek kayıt → ayrıştıracak bir şey yok
326
+ with_fields = [r for r in recs if r["fields"]]
327
+ unknown = [r for r in recs if not r["fields"]]
328
+ if len(with_fields) < 2:
329
+ continue # alan sinyali yok → karar verilemez (atla)
330
+
331
+ comps = _field_components(with_fields)
332
+ if len(comps) < 2:
333
+ continue # tek (zincirlenmiş) grup → aynı kişi, ayrıştırma yok
334
+
335
+ comps.sort(key=len, reverse=True) # en büyük grup düz kalır
336
+ groups = []
337
+ for gi, comp in enumerate(comps):
338
+ gfields = sorted({f for r in comp for f in r["fields"]})
339
+ groups.append({
340
+ "records": sorted({r["record"] for r in comp}),
341
+ "fields": gfields[:8],
342
+ "suffix": suffixes[gi] if gi < len(suffixes) else f"({gi})",
343
+ })
344
+ field_summ = " | ".join("[" + ", ".join(g["fields"][:3]) + "]" for g in groups)
345
+ reason = f"Same name, {len(groups)} field-disjoint groups: {field_summ}"
346
+ tier = 1
347
+ if unknown:
348
+ tier = 2
349
+ reason += f"; also {len(unknown)} record(s) without field info"
350
+ out.append({
351
+ "split_id": f"{norm.replace(' ', '_')}_{len(out)}",
352
+ "name": name_of[norm],
353
+ "norm": norm,
354
+ "tier": tier,
355
+ "groups": groups,
356
+ "reason": reason,
357
+ })
358
+ return out
359
+
360
+
361
+ def build_affiliation_blocks(
362
+ df: pd.DataFrame,
363
+ sim_threshold: float = 0.85,
364
+ mode: str = "auto",
365
+ auto_threshold: float = 0.95,
366
+ should_cancel: CancelCheck = None,
367
+ ) -> list[dict]:
368
+ """Affiliation varyantlarını ilk-anlamlı-token bazlı blocking. Bkz. build_author_blocks."""
369
+ if "C1" not in df.columns and "C1raw" not in df.columns:
370
+ return []
371
+
372
+ # canonical form -> list of variants
373
+ blocks: dict[str, list[dict]] = defaultdict(list)
374
+ for _ri, (idx, row) in enumerate(df.iterrows()):
375
+ if _is_cancelled(should_cancel, _ri):
376
+ return []
377
+ affs = _split_affs(row.get("C1", "") or row.get("C1raw", ""))
378
+ for aff in affs:
379
+ norm = normalize_name(aff)
380
+ if not norm:
381
+ continue
382
+ # ilk anlamlı token (stopword olmayan)
383
+ tokens = [t for t in norm.split() if t not in {"the", "of", "and", "for", "univ", "university"}]
384
+ key = tokens[0] if tokens else norm[:5]
385
+ blocks[key].append({
386
+ "record": int(idx),
387
+ "raw": aff,
388
+ "norm": norm,
389
+ })
390
+
391
+ out: list[dict] = []
392
+ for key, members in blocks.items():
393
+ if should_cancel and should_cancel():
394
+ return out
395
+ if len({m["norm"] for m in members}) < 2:
396
+ continue
397
+
398
+ auto_clusters: list[dict] = []
399
+ llm_members: list[dict] = []
400
+ for ci, comp in enumerate(_greedy_cluster(members, sim_threshold)):
401
+ comp_norms = {m["norm"] for m in comp}
402
+ if len(comp_norms) < 2:
403
+ continue # tek varyant → birleştirme yok (Tier 3)
404
+ cand = {
405
+ "id": f"{key}_{ci}",
406
+ "variants": sorted({m["raw"] for m in comp}),
407
+ "records": sorted({m["record"] for m in comp})[:100],
408
+ }
409
+ # Affiliation: yüksek string benzerliği tek başına güçlü sinyal (bağlam yok).
410
+ if _min_pairwise_jw(comp_norms) >= auto_threshold:
411
+ cand["tier"] = 1
412
+ auto_clusters.append(cand)
413
+ else:
414
+ cand["tier"] = 2
415
+ llm_members.append(cand)
416
+
417
+ block: dict = {"key": key}
418
+ if auto_clusters:
419
+ block["auto"] = auto_clusters
420
+ if llm_members:
421
+ block["members"] = llm_members
422
+ if block.get("auto") or block.get("members"):
423
+ out.append(block)
424
+
425
+ return out
426
+
427
+
428
+ def build_country_blocks(
429
+ df: pd.DataFrame,
430
+ sim_threshold: float = 0.85,
431
+ mode: str = "auto",
432
+ auto_threshold: float = 0.95,
433
+ should_cancel: CancelCheck = None,
434
+ ) -> list[dict]:
435
+ """Ülke adı standartlaştırma — C1'in ülke bileşeni.
436
+
437
+ Tier 1 (deterministik): ISO sözlüğü varyantı kanonik ada eşler (USA -> United States).
438
+ Tier 2 (LLM): sözlükte olmayan tokenler benzerlik bazlı kümelenip hakeme sorulur.
439
+ """
440
+ from bibex_core.modules.c1_utils import canonical_country, extract_country_from_c1, _is_org
441
+
442
+ if "C1" not in df.columns and "C1raw" not in df.columns:
443
+ return []
444
+
445
+ resolved: dict[str, dict] = {} # canonical -> {variants, records}
446
+ unresolved: dict[str, set] = {} # token(lower) -> records
447
+ unresolved_disp: dict[str, str] = {}
448
+ for _ri, (idx, row) in enumerate(df.iterrows()):
449
+ if _is_cancelled(should_cancel, _ri):
450
+ return []
451
+ val = row.get("C1") or row.get("C1raw") or ""
452
+ for tok in extract_country_from_c1(val):
453
+ t = tok.strip()
454
+ if not t:
455
+ continue
456
+ canon = canonical_country(t)
457
+ if canon:
458
+ r = resolved.setdefault(canon, {"variants": set(), "records": set()})
459
+ r["variants"].add(t)
460
+ r["records"].add(int(idx))
461
+ else:
462
+ # Açıkça-ülke-olmayan tokenleri (kurum / posta kodu) LLM'e GÖNDERME —
463
+ # kurum=ülke karmasını kaynağında önler. Şehirler LLM'e gider ve prompt
464
+ # onları 'uncertain'e atar (önerilmez). Eksik ülke ayrı API eylemiyle tamamlanır.
465
+ if _is_org(t) or any(ch.isdigit() for ch in t):
466
+ continue
467
+ key = t.lower()
468
+ unresolved.setdefault(key, set()).add(int(idx))
469
+ unresolved_disp.setdefault(key, t)
470
+
471
+ auto_clusters: list[dict] = []
472
+ for canon, data in resolved.items():
473
+ variants = sorted(data["variants"])
474
+ if all(v.strip().lower() == canon.lower() for v in variants):
475
+ continue # zaten kanonik — değişiklik yok
476
+ auto_clusters.append({
477
+ "id": f"country_{re.sub(r'[^a-z0-9]+', '_', canon.lower()).strip('_')}",
478
+ "variants": variants,
479
+ "canonical_name": canon,
480
+ "records": sorted(data["records"])[:1000],
481
+ "tier": 1,
482
+ })
483
+
484
+ if should_cancel and should_cancel():
485
+ return []
486
+ members = [
487
+ {"record": min(recs), "raw": unresolved_disp[k], "norm": normalize_name(unresolved_disp[k]), "_recs": recs}
488
+ for k, recs in unresolved.items()
489
+ ]
490
+ llm_members: list[dict] = []
491
+ for ci, comp in enumerate(_greedy_cluster(members, sim_threshold)):
492
+ recs: set[int] = set()
493
+ variants_u: set[str] = set()
494
+ for m in comp:
495
+ variants_u.add(m["raw"])
496
+ recs |= m["_recs"]
497
+ llm_members.append({
498
+ "id": f"country_u_{ci}",
499
+ "variants": sorted(variants_u),
500
+ "records": sorted(recs)[:1000],
501
+ "tier": 2,
502
+ })
503
+
504
+ block: dict = {"key": "country"}
505
+ if auto_clusters:
506
+ block["auto"] = auto_clusters
507
+ if llm_members:
508
+ block["members"] = llm_members
509
+ return [block] if (auto_clusters or llm_members) else []
510
+
511
+
512
+ def build_org_rollup(
513
+ df: pd.DataFrame,
514
+ sim_threshold: float = 0.85,
515
+ mode: str = "auto",
516
+ auto_threshold: float = 0.95,
517
+ should_cancel: CancelCheck = None,
518
+ ) -> list[dict]:
519
+ """Affiliation → üst kurum toplama.
520
+
521
+ Her C1 adresini ayrıştırıp üst kurumu çıkarır; aynı kuruma ait adres varyantlarını
522
+ kümeler. Tier 1: tek-net adres veya yüksek kurum-adı benzerliği. Tier 2: sınırda
523
+ varyantlar LLM hakemliğine. Apply eşleşen TAM adresi üst kurumla DEĞİŞTİRİR.
524
+ """
525
+ from collections import Counter
526
+ from bibex_core.modules.c1_utils import parse_c1_address, split_c1_addresses
527
+
528
+ if "C1" not in df.columns and "C1raw" not in df.columns:
529
+ return []
530
+
531
+ blocks: dict[str, list[dict]] = defaultdict(list)
532
+ for _ri, (idx, row) in enumerate(df.iterrows()):
533
+ if _is_cancelled(should_cancel, _ri):
534
+ return []
535
+ val = row.get("C1") or row.get("C1raw") or ""
536
+ for addr in split_c1_addresses(val):
537
+ info = parse_c1_address(addr)
538
+ inst = info["institution"]
539
+ if not inst:
540
+ continue # parse edilemedi → atla (yanlış rollup yazma)
541
+ norm = normalize_name(inst)
542
+ if not norm:
543
+ continue
544
+ toks = [t for t in norm.split() if t not in {"the", "of", "and", "for", "univ", "university"}]
545
+ key = toks[0] if toks else norm[:5]
546
+ blocks[key].append({"record": int(idx), "raw": addr, "norm": norm, "parent": inst})
547
+
548
+ out: list[dict] = []
549
+ for key, members in blocks.items():
550
+ if should_cancel and should_cancel():
551
+ return out
552
+ auto_clusters: list[dict] = []
553
+ llm_members: list[dict] = []
554
+ for ci, comp in enumerate(_greedy_cluster(members, sim_threshold)):
555
+ parent = Counter(m["parent"] for m in comp).most_common(1)[0][0]
556
+ variants = sorted({m["raw"] for m in comp})
557
+ if len(variants) == 1 and variants[0].strip() == parent.strip():
558
+ continue # tek adres zaten üst kuruma eşit
559
+ cand = {
560
+ "id": f"org_{key}_{ci}",
561
+ "variants": variants, # tam orijinal adresler (apply bunları değiştirir)
562
+ "canonical_name": parent, # önerilen üst kurum
563
+ "records": sorted({m["record"] for m in comp})[:1000],
564
+ }
565
+ comp_norms = {m["norm"] for m in comp}
566
+ if len(comp_norms) < 2 or _min_pairwise_jw(comp_norms) >= auto_threshold:
567
+ cand["tier"] = 1
568
+ auto_clusters.append(cand)
569
+ else:
570
+ cand["tier"] = 2
571
+ llm_members.append(cand)
572
+
573
+ block: dict = {"key": key}
574
+ if auto_clusters:
575
+ block["auto"] = auto_clusters
576
+ if llm_members:
577
+ block["members"] = llm_members
578
+ if block.get("auto") or block.get("members"):
579
+ out.append(block)
580
+
581
+ return out
@@ -0,0 +1,47 @@
1
+ """Hash tabanlı LLM yanıt cache'i — aynı blok 2. çağrıda LLM'e gitmesin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import sqlite3
8
+ from pathlib import Path
9
+ from typing import Any, Optional
10
+
11
+
12
+ class DisambiguationCache:
13
+ def __init__(self, project_dir: Path):
14
+ self.path = project_dir / "disambiguation_cache.sqlite"
15
+ self.conn = sqlite3.connect(self.path)
16
+ self.conn.execute(
17
+ "CREATE TABLE IF NOT EXISTS cache ("
18
+ "key TEXT PRIMARY KEY, "
19
+ "value TEXT NOT NULL, "
20
+ "created_at REAL NOT NULL"
21
+ ")"
22
+ )
23
+ self.conn.commit()
24
+
25
+ @staticmethod
26
+ def hash_key(payload: Any) -> str:
27
+ s = json.dumps(payload, sort_keys=True, ensure_ascii=False)
28
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()[:32]
29
+
30
+ def get(self, key: str) -> Optional[dict]:
31
+ cur = self.conn.execute("SELECT value FROM cache WHERE key = ?", (key,))
32
+ row = cur.fetchone()
33
+ return json.loads(row[0]) if row else None
34
+
35
+ def set(self, key: str, value: dict) -> None:
36
+ import time
37
+ self.conn.execute(
38
+ "INSERT OR REPLACE INTO cache(key, value, created_at) VALUES (?, ?, ?)",
39
+ (key, json.dumps(value, ensure_ascii=False), time.time()),
40
+ )
41
+ self.conn.commit()
42
+
43
+ def close(self):
44
+ try:
45
+ self.conn.close()
46
+ except Exception:
47
+ pass