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,985 @@
1
+ """Disambiguation pipeline orchestration — blocking -> LLM -> sonuç."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import pandas as pd
12
+ from fastapi import HTTPException
13
+
14
+ from config import settings
15
+ from jobs.runner import JobContext, run_cpu
16
+ from services import analyses, merger, storage
17
+ from .blocking import (
18
+ build_author_blocks, build_affiliation_blocks, build_author_splits,
19
+ build_country_blocks, build_org_rollup,
20
+ )
21
+ from .cache import DisambiguationCache
22
+ from . import orcid as orcid_mod
23
+ from .deepseek_client import (
24
+ DeepSeekClient,
25
+ AUTHOR_SYSTEM_PROMPT,
26
+ AUTHOR_SPLIT_SYSTEM_PROMPT,
27
+ AFFILIATION_SYSTEM_PROMPT,
28
+ COUNTRY_SYSTEM_PROMPT,
29
+ ORG_ROLLUP_SYSTEM_PROMPT,
30
+ )
31
+
32
+
33
+ def _project_dir(project_id: str) -> Path:
34
+ """Disambiguation çalışma klasörü = AKTİF ANALİZ klasörü (proje kökü değil).
35
+
36
+ Böylece öneriler (disambiguation_*.json), snapshot'lar ve audit'ler ait oldukları
37
+ analizin içinde tutulur; yeni bir merge (yeni analiz) eski önerileri TAŞIMAZ —
38
+ otomatik temiz başlangıç. Hiç analiz yoksa proje köküne düşer (geriye uyumlu).
39
+ """
40
+ if storage.get_project(project_id) is None:
41
+ raise HTTPException(404, "project_not_found")
42
+ return analyses.work_dir(project_id)
43
+
44
+
45
+ def _load_merged(project_id: str) -> pd.DataFrame:
46
+ p = merger.merged_dataset_path(project_id)
47
+ if p is None:
48
+ raise HTTPException(409, "no_merged_data")
49
+ return pd.read_excel(p)
50
+
51
+
52
+ def _distinct_variants(cluster: dict) -> set[str]:
53
+ """Bir kümenin birleştirdiği farklı yazım/varyant kümesi.
54
+
55
+ Üye objesi `name_variants` (yazar) veya `variants` (affiliation/org/ülke)
56
+ taşır; hiçbiri yoksa üyenin kendi `id`'si tek varyant sayılır. <2 varyant =
57
+ birleştirilecek bir şey yok (tek-üyeli LLM kümesi) → öneri olarak gösterilmez.
58
+ """
59
+ out: set[str] = set()
60
+ for m in cluster.get("members", []):
61
+ vs = m.get("name_variants") or m.get("variants") or ([m["id"]] if m.get("id") else [])
62
+ out.update(v for v in vs if v)
63
+ return out
64
+
65
+
66
+ def _label_for_llm(members: list[dict], variant_key: str,
67
+ context_keys: tuple = ()) -> tuple[list[dict], dict]:
68
+ """Her VARYANTI tek tek v1..vN ile etiketleyip LLM'e gonder.
69
+
70
+ Neden varyant duzeyi (aday duzeyi degil): aday duzeyinde etiketlemede,
71
+ cok-varyantli tek bir aday ('v1') icindeki varyantlari LLM 'v1_10' gibi
72
+ ALT-INDEKSLIYOR; bu etiketler aday id'siyle eslesmiyor ve kart 'v1_10'
73
+ gibi gorunuyordu. Her varyanta kendi 'vN' etiketini verince LLM'in
74
+ alt-indekslemesine gerek kalmaz; donen yanit dogrudan haritalanir.
75
+
76
+ Her oge: {id:'vN', <name|text>: varyant, **baglam}. Donen:
77
+ (LLM ogeleri, {etiket: ham varyant dizesi}).
78
+ """
79
+ text_field = "name" if variant_key == "name_variants" else "text"
80
+ items: list[dict] = []
81
+ by_label: dict[str, str] = {}
82
+ n = 0
83
+ for m in members:
84
+ vs = m.get(variant_key) or ([m["id"]] if m.get("id") else [])
85
+ ctx = {k: m[k] for k in context_keys if m.get(k)}
86
+ for v in vs:
87
+ if not v:
88
+ continue
89
+ n += 1
90
+ label = f"v{n}"
91
+ items.append({"id": label, text_field: v, **ctx})
92
+ by_label[label] = v
93
+ return items, by_label
94
+
95
+
96
+ def _members_from_labels(cluster: dict, by_label: dict, variant_key: str) -> list[dict]:
97
+ """LLM yanitindaki member_ids (v-etiketleri) GERCEK varyant dizelerine cevir;
98
+ her varyant icin tek bir uye olustur. Bilinmeyen etiket -> ham dize (son care)."""
99
+ out: list[dict] = []
100
+ seen: set[str] = set()
101
+ for mid in (cluster.get("member_ids") or []):
102
+ v = by_label.get(str(mid).strip(), str(mid))
103
+ if not v or v in seen:
104
+ continue
105
+ seen.add(v)
106
+ out.append({"id": v, variant_key: [v]})
107
+ return out
108
+
109
+
110
+ def _member_variants(m: dict, key: str) -> list:
111
+ """Bir üyenin varyant listesi; boşsa üyenin kendi 'id'sine düş (LLM üyeleri
112
+ varyantı çoğu zaman 'id' alanında taşır)."""
113
+ vs = m.get(key) or ([m["id"]] if m.get("id") else [])
114
+ return [v for v in vs if v]
115
+
116
+
117
+ def _apply_c1_map(df: pd.DataFrame, cols: list[str], fn) -> int:
118
+ """C1/C1raw kolonlarına fn uygula ve GERÇEKTEN değişen satır sayısını döndür.
119
+
120
+ Eskiden affected=len(mapping) idi: eşleşme hiç tutmasa bile sıfır-olmayan bir
121
+ sayı raporluyordu (kullanıcıya '109 değişti' der ama veri değişmezdi). Artık
122
+ yalnız fiilen değişen satırlar sayılır (herhangi bir kolonda)."""
123
+ changed = pd.Series(False, index=df.index)
124
+ for col in cols:
125
+ before = df[col].astype(str)
126
+ after = before.apply(fn)
127
+ df[col] = after
128
+ changed = changed | (after != before)
129
+ return int(changed.sum())
130
+
131
+
132
+ def _client() -> DeepSeekClient:
133
+ api_key = settings.effective_llm_api_key
134
+ if not api_key:
135
+ raise HTTPException(400, "llm_key_missing")
136
+ return DeepSeekClient(
137
+ api_key=api_key,
138
+ base_url=settings.effective_llm_base_url,
139
+ model=settings.effective_llm_model,
140
+ )
141
+
142
+
143
+ def _snapshot(project_dir: Path, df: pd.DataFrame, kind: str) -> Path:
144
+ snaps = project_dir / "snapshots"
145
+ snaps.mkdir(exist_ok=True)
146
+ stamp = time.strftime("%Y%m%d_%H%M%S")
147
+ p = snaps / f"pre_{kind}_{stamp}.xlsx"
148
+ df.to_excel(p, index=False)
149
+ return p
150
+
151
+
152
+ # ── ORCID çözümü SENKRON ağ I/O yapar (orcids_for_candidate → fetch_orcids_for_doi →
153
+ # requests). Bu yüzden ASLA event loop üzerinde await'siz çağrılmamalı: aksi halde scan
154
+ # sırasında loop bloklanır ve cancel/navigasyon/export DONAR (test bulgusu #7'nin ayrı
155
+ # kök nedeni). Aşağıdaki yardımcılar asyncio.to_thread ile thread'e alınır; should_cancel
156
+ # ile iptalde erken çıkarlar. ──
157
+ def _resolve_member_orcid_sets(df, members, email, allow_api, should_cancel=None) -> list[set]:
158
+ """Üye başına ORCID seti (df-first + opsiyonel anlık API). Thread'de çalıştırılmalı."""
159
+ out: list[set] = []
160
+ for m in members:
161
+ if should_cancel and should_cancel():
162
+ break
163
+ out.append(orcid_mod.orcids_for_candidate(df, m, email=email, allow_api=allow_api))
164
+ return out
165
+
166
+
167
+ def _resolve_split_group_sets(df, groups, name, email, allow_api, should_cancel=None) -> list[set]:
168
+ """Bir split önerisinin grup-başına ORCID setleri. Thread'de çalıştırılmalı."""
169
+ out: list[set] = []
170
+ for g in groups:
171
+ if should_cancel and should_cancel():
172
+ break
173
+ cand = {"name_variants": [name], "records": g.get("records", [])}
174
+ out.append(orcid_mod.orcids_for_candidate(df, cand, email=email, allow_api=allow_api))
175
+ return out
176
+
177
+
178
+ async def _compute_author_splits(ctx: JobContext, project_dir: Path, df) -> list[dict]:
179
+ """Yazar AYRIŞTIRMA önerileri. Tier 1 (net, alan-ayrık) deterministik;
180
+ Tier 2 (sınırda, alan-yok kayıt var) LLM hakemliğine — LLM 'keep' derse öneri düşer."""
181
+ splits = await run_cpu(build_author_splits, df, should_cancel=lambda: ctx.cancelled)
182
+ if not splits:
183
+ ctx.log("Splitting: no names to split.")
184
+ return []
185
+
186
+ # ── ORCID override: aynı isim, alan-ayrık gruplar gerçekten farklı kişiler mi? ──
187
+ # Grup başına ORCID çöz (df-first, eksikse anlık API). ORCID altın standarttır:
188
+ # • Tek ortak ORCID ≥2 gruba yayılıyorsa → TEK interdisipliner kişi → ASLA bölme.
189
+ # • Gruplar farklı ORCID'lerdeyse → kesin farklı kişiler → güçlü split (LLM'siz).
190
+ email = settings.crossref_email or None
191
+ allow_api = settings.disambiguation_orcid_lookup
192
+ orcid_keep = 0
193
+ orcid_split = 0
194
+ for s in splits:
195
+ if ctx.cancelled:
196
+ break
197
+ # SENKRON ağ I/O → thread'e al (event loop'u bloklama; cancel/nav/export donmasın).
198
+ group_sets = await asyncio.to_thread(
199
+ _resolve_split_group_sets, df, s.get("groups", []), s["name"], email, allow_api,
200
+ lambda: ctx.cancelled,
201
+ )
202
+ known = [gs for gs in group_sets if gs]
203
+ if len(known) >= 2:
204
+ # Tüm bilinen gruplar ortak bir ORCID paylaşıyor mu?
205
+ if set.intersection(*known):
206
+ s["decision"] = "keep" # aynı kişi → bölme
207
+ s["source"] = "orcid"
208
+ s["reason"] = "Same ORCID across groups — one interdisciplinary person"
209
+ orcid_keep += 1
210
+ else:
211
+ s["decision"] = "split" # farklı ORCID'ler → kesin böl
212
+ s["source"] = "orcid"
213
+ s["tier"] = 1
214
+ s["confidence"] = 1.0
215
+ s["reason"] = "Different ORCID per group — distinct people"
216
+ orcid_split += 1
217
+ if orcid_keep or orcid_split:
218
+ ctx.log(f"ORCID split: {orcid_split} confirmed, {orcid_keep} kept (same person)")
219
+
220
+ # ORCID kararı verilmemiş (source != 'orcid') öneriler eski akışa düşer
221
+ undecided = [s for s in splits if s.get("source") != "orcid"]
222
+ tier1 = [s for s in undecided if s.get("tier") == 1]
223
+ tier2 = [s for s in undecided if s.get("tier") == 2]
224
+ for s in tier1:
225
+ s["source"] = "deterministic"
226
+ s["decision"] = "split"
227
+ ctx.log(f"Splitting — Tier 1 (auto): {len(tier1)}, Tier 2 (borderline): {len(tier2)}")
228
+
229
+ if tier2:
230
+ client = None
231
+ try:
232
+ client = _client()
233
+ except HTTPException:
234
+ client = None # API anahtarı yoksa manuel incelemeye bırak
235
+ if client is not None:
236
+ cache = DisambiguationCache(project_dir)
237
+ try:
238
+ for s in tier2:
239
+ if ctx.cancelled:
240
+ break
241
+ payload = {"task": "author_split", "name": s["name"],
242
+ "groups": [{"fields": g["fields"]} for g in s["groups"]]}
243
+ ck = cache.hash_key(payload)
244
+ resp = cache.get(ck)
245
+ if resp is None:
246
+ try:
247
+ resp = await client.chat_json_async(AUTHOR_SPLIT_SYSTEM_PROMPT, payload)
248
+ cache.set(ck, resp)
249
+ except Exception as e:
250
+ ctx.log(f"Split LLM error ({s['name']}): {e}")
251
+ resp = {}
252
+ s["source"] = "llm"
253
+ s["decision"] = resp.get("decision", "review") # split | keep | review
254
+ s["confidence"] = resp.get("confidence")
255
+ if resp.get("reason"):
256
+ s["reason"] = f"{s['reason']} | AI: {resp['reason']}"
257
+ finally:
258
+ cache.close()
259
+ else:
260
+ for s in tier2:
261
+ s["source"] = "manual"
262
+ s["decision"] = "review"
263
+
264
+ # 'keep' (LLM ya da ORCID: tek disiplinlerarası kişi) önerileri listeden çıkar.
265
+ # ORCID ile karar verilenler (split) de dahil — tüm splits üzerinden filtrele.
266
+ return [s for s in splits if s.get("decision") != "keep"]
267
+
268
+
269
+ async def run_author_disambiguation(
270
+ ctx: JobContext, project_id: str, mode: str = "auto",
271
+ max_records: int | None = None,
272
+ ) -> dict[str, Any]:
273
+ project_dir = _project_dir(project_id)
274
+ df = await asyncio.to_thread(_load_merged, project_id)
275
+ ctx.log(f"Loaded: {len(df)} records")
276
+ if max_records and max_records > 0 and len(df) > max_records:
277
+ df = df.head(max_records)
278
+ ctx.log(f"Test/cost mode: limited to first {max_records} records")
279
+ ctx.log(f"Mode: {mode}")
280
+ ctx.progress(0.05)
281
+
282
+ # 1) Blocking
283
+ ctx.log("Blocking + similarity analysis...")
284
+ blocks = await run_cpu(
285
+ build_author_blocks, df,
286
+ settings.disambiguation_blocking_threshold, mode,
287
+ settings.disambiguation_auto_approve_threshold,
288
+ should_cancel=lambda: ctx.cancelled,
289
+ )
290
+ ctx.log(f"{len(blocks)} candidate merge blocks found")
291
+ ctx.progress(0.15)
292
+
293
+ all_clusters: list[dict] = []
294
+ all_uncertain: list[dict] = []
295
+
296
+ # 1.5) Tier 1 — deterministik auto-merge önerileri (LLM'siz, confidence=1.0)
297
+ auto_count = 0
298
+ for block in blocks:
299
+ for ac in block.get("auto", []):
300
+ variants = ac.get("name_variants", [])
301
+ all_clusters.append({
302
+ "cluster_id": ac["id"],
303
+ "block_key": block["key"],
304
+ "members": [ac],
305
+ "canonical": variants[0] if variants else None,
306
+ "confidence": 1.0,
307
+ "source": "deterministic",
308
+ "tier": 1,
309
+ "reason": "High similarity + shared context (automatic)",
310
+ })
311
+ auto_count += 1
312
+ if auto_count:
313
+ ctx.log(f"Tier 1 (automatic, no LLM): {auto_count} proposals")
314
+
315
+ # ORCID write-back istatistiği (Part 2d)
316
+ orcid_stats = {"rows_filled": 0, "dois_fetched": 0}
317
+ email = settings.crossref_email or None
318
+ allow_api = settings.disambiguation_orcid_lookup
319
+
320
+ # 2) Tier 2 — belirsiz bloklar. ÖNCE ORCID kararı (df-first, eksikse anlık API),
321
+ # sonra gerekiyorsa LLM. ORCID altın standart: aynı=merge, farklı=ayrı.
322
+ llm_blocks = [b for b in blocks if b.get("members")]
323
+ ctx.log(f"Tier 2 (review): {len(llm_blocks)} blocks")
324
+ orcid_merges = 0
325
+ orcid_conflicts = 0
326
+ if llm_blocks:
327
+ client = None
328
+ cache = DisambiguationCache(project_dir)
329
+ try:
330
+ for i, block in enumerate(llm_blocks):
331
+ if ctx.cancelled:
332
+ ctx.log("Cancelled")
333
+ break
334
+
335
+ members = block["members"]
336
+ # 2.1) ORCID ön-pass — üye başına ORCID setini çöz (df-first + on-demand).
337
+ # SENKRON ağ I/O → thread'e al (event loop'u bloklama; aksi halde scan
338
+ # sırasında cancel/navigasyon/export DONAR — #7 ek kök neden).
339
+ orcid_sets = await asyncio.to_thread(
340
+ _resolve_member_orcid_sets, df, members, email, allow_api,
341
+ lambda: ctx.cancelled,
342
+ )
343
+ verdict = orcid_mod.classify_by_orcid(orcid_sets)
344
+
345
+ if verdict == "merge":
346
+ # Tüm ORCID-bilinen üyeler aynı kişi → Tier 1 auto-merge (LLM'siz)
347
+ shared = sorted(set.intersection(*[s for s in orcid_sets if s]))
348
+ variants = sorted({v for m in members for v in (m.get("name_variants") or [])})
349
+ if len(set(variants)) >= 2:
350
+ all_clusters.append({
351
+ "cluster_id": f"{block['key']}__orcid",
352
+ "block_key": block["key"],
353
+ "members": members,
354
+ "canonical": variants[0] if variants else None,
355
+ "confidence": 1.0,
356
+ "source": "orcid",
357
+ "tier": 1,
358
+ "reason": f"Same ORCID ({', '.join(shared)})",
359
+ })
360
+ orcid_merges += 1
361
+ ctx.progress(0.15 + 0.8 * ((i + 1) / len(llm_blocks)))
362
+ continue # LLM'e gitme
363
+
364
+ if verdict == "conflict":
365
+ # Ayrık ORCID'ler → farklı kişiler → birleştirme YOK (LLM'e gitme)
366
+ orcid_conflicts += 1
367
+ block_names = sorted({v for m in members for v in (m.get("name_variants") or [])})
368
+ all_uncertain.append({
369
+ "block_key": block["key"],
370
+ "names": block_names,
371
+ "reason": "Different ORCIDs — distinct people (not merged)",
372
+ })
373
+ ctx.progress(0.15 + 0.8 * ((i + 1) / len(llm_blocks)))
374
+ continue
375
+
376
+ # 2.2) ORCID belirsiz → LLM hakemliği (mevcut akış). LLM yapılandırılmamışsa
377
+ # bloğu manuel incelemeye bırak (job çökmesin).
378
+ if client is None:
379
+ try:
380
+ client = _client()
381
+ except HTTPException:
382
+ client = False # anahtar yok — bu çalıştırmada LLM kullanılmaz
383
+ if not client:
384
+ block_names = sorted({v for m in members for v in (m.get("name_variants") or [])})
385
+ all_uncertain.append({
386
+ "block_key": block["key"], "names": block_names,
387
+ "reason": "No ORCID and no LLM configured — manual review",
388
+ })
389
+ ctx.progress(0.15 + 0.8 * ((i + 1) / len(llm_blocks)))
390
+ continue
391
+ items, by_label = _label_for_llm(members, "name_variants", ("affiliations", "coauthors", "year_range"))
392
+ payload = {"task": "author_disambiguation", "candidates": items}
393
+ cache_key = cache.hash_key(payload)
394
+ cached = cache.get(cache_key)
395
+ if cached:
396
+ resp = cached
397
+ ctx.log(f"[{i+1}/{len(llm_blocks)}] cache hit ({block['key']})")
398
+ else:
399
+ try:
400
+ resp = await client.chat_json_async(AUTHOR_SYSTEM_PROMPT, payload)
401
+ cache.set(cache_key, resp)
402
+ ctx.log(f"[{i+1}/{len(llm_blocks)}] LLM ({block['key']}): {len(resp.get('clusters', []))} cluster")
403
+ except Exception as e:
404
+ ctx.log(f"[{i+1}/{len(llm_blocks)}] ERROR: {e}")
405
+ continue
406
+
407
+ for c in resp.get("clusters", []):
408
+ c["block_key"] = block["key"]
409
+ c["cluster_id"] = f"{block['key']}__{c.get('cluster_id', 'c')}" # global benzersiz (bloklar arası c1/c2 çakışmasın)
410
+ c["source"] = "llm"
411
+ c["tier"] = 2
412
+ # member_ids (v-etiketleri) -> gercek isim varyantlari
413
+ c["members"] = _members_from_labels(c, by_label, "name_variants")
414
+ if len(_distinct_variants(c)) < 2:
415
+ continue # tek varyant — birleştirilecek bir şey yok, boş kart olmasın
416
+ all_clusters.append(c)
417
+ for u in resp.get("uncertain", []):
418
+ u["block_key"] = block["key"]
419
+ all_uncertain.append(u)
420
+
421
+ ctx.progress(0.15 + 0.8 * ((i + 1) / len(llm_blocks)))
422
+ finally:
423
+ cache.close()
424
+ if orcid_merges or orcid_conflicts:
425
+ ctx.log(f"ORCID: {orcid_merges} auto-merge, {orcid_conflicts} conflict (distinct people)")
426
+
427
+ ctx.progress(0.7)
428
+
429
+ # 3) Ayrıştırma (split) önerileri — aynı isim, alan bakımından ayrık gruplar
430
+ ctx.log("Author splitting analysis (same spelling, different field)...")
431
+ splits = await _compute_author_splits(ctx, project_dir, df)
432
+ ctx.progress(0.9)
433
+
434
+ # 4) Önerileri kaydet (henüz uygulanmadı — onay bekliyor)
435
+ proposals_path = project_dir / "disambiguation_authors.json"
436
+ proposals = {
437
+ "kind": "authors",
438
+ "generated_at": time.time(),
439
+ "auto_approve_threshold": settings.disambiguation_auto_approve_threshold,
440
+ "clusters": all_clusters,
441
+ "splits": splits,
442
+ "uncertain": all_uncertain,
443
+ }
444
+ proposals_path.write_text(json.dumps(proposals, ensure_ascii=False, indent=2), encoding="utf-8")
445
+ ctx.log(
446
+ f"Merging: {len(all_clusters)} proposals ({auto_count} automatic + {len(all_clusters) - auto_count} LLM); "
447
+ f"Splitting: {len(splits)} proposals"
448
+ )
449
+
450
+ # 5) Write-back — ambigu satırlarda DOI'den çekilen OI/RI/ROR/CC'yi veriye kalıcı
451
+ # yaz (fetch boşa gitmesin; sonra Fill-all gerekmesin). Yalnız boş hücrelere.
452
+ snap_rel = None
453
+ if settings.disambiguation_orcid_persist and not ctx.cancelled:
454
+ ambiguous_rows: list[int] = []
455
+ for b in llm_blocks:
456
+ for m in b.get("members", []):
457
+ ambiguous_rows.extend(m.get("records", []))
458
+ for s in splits:
459
+ for g in s.get("groups", []):
460
+ ambiguous_rows.extend(g.get("records", []))
461
+ if ambiguous_rows:
462
+ df_before = df.copy(deep=True)
463
+ wb = await asyncio.to_thread(
464
+ orcid_mod.enrich_rows_from_doi, df, ambiguous_rows,
465
+ email=(settings.crossref_email or None),
466
+ )
467
+ orcid_stats["rows_filled"] = wb["rows_filled"]
468
+ orcid_stats["dois_fetched"] = wb["dois_fetched"]
469
+ if wb["rows_filled"]:
470
+ src = merger.merged_dataset_path(project_id)
471
+ if src is not None:
472
+ snap_rel = _snapshot(project_dir, df_before, "orcid_enrich")
473
+ await asyncio.to_thread(df.to_excel, src, index=False)
474
+ try:
475
+ from services.filter_engine import _DF_CACHE
476
+ _DF_CACHE.clear()
477
+ except Exception:
478
+ pass
479
+ storage.touch_project(project_id)
480
+ ctx.log(f"ORCID write-back: {wb['rows_filled']} rows enriched (OI/ROR/CC)")
481
+
482
+ ctx.progress(1.0)
483
+ return {
484
+ "task": "authors",
485
+ "candidates": int(sum(len(b.get("members", [])) + len(b.get("auto", [])) for b in blocks)),
486
+ "clusters_proposed": len(all_clusters),
487
+ "splits_proposed": len(splits),
488
+ "auto": auto_count,
489
+ "uncertain": len(all_uncertain),
490
+ "orcid_rows_filled": orcid_stats["rows_filled"],
491
+ "dois_fetched": orcid_stats["dois_fetched"],
492
+ "snapshot": str(snap_rel.relative_to(storage.settings.storage_path)) if snap_rel else None,
493
+ }
494
+
495
+
496
+ async def run_affiliation_disambiguation(
497
+ ctx: JobContext, project_id: str, mode: str = "auto",
498
+ max_records: int | None = None,
499
+ ) -> dict[str, Any]:
500
+ project_dir = _project_dir(project_id)
501
+ df = await asyncio.to_thread(_load_merged, project_id)
502
+ ctx.log(f"Loaded: {len(df)} records")
503
+ if max_records and max_records > 0 and len(df) > max_records:
504
+ df = df.head(max_records)
505
+ ctx.log(f"Test/cost mode: limited to first {max_records} records")
506
+ ctx.log(f"Mode: {mode}")
507
+ ctx.progress(0.05)
508
+
509
+ ctx.log("Affiliation blocking + similarity analysis...")
510
+ blocks = await run_cpu(
511
+ build_affiliation_blocks, df,
512
+ settings.disambiguation_blocking_threshold, mode,
513
+ settings.disambiguation_auto_approve_threshold,
514
+ should_cancel=lambda: ctx.cancelled,
515
+ )
516
+ ctx.log(f"{len(blocks)} candidate blocks found")
517
+ ctx.progress(0.15)
518
+
519
+ if not blocks:
520
+ ctx.log("No affiliation variants to merge.")
521
+ return {"task": "affiliations", "candidates": 0, "clusters": [], "uncertain": []}
522
+
523
+ all_clusters: list[dict] = []
524
+ all_uncertain: list[dict] = []
525
+
526
+ # Tier 1 — deterministik (yüksek string benzerliği, LLM'siz)
527
+ auto_count = 0
528
+ for block in blocks:
529
+ for ac in block.get("auto", []):
530
+ variants = ac.get("variants", [])
531
+ all_clusters.append({
532
+ "cluster_id": ac["id"],
533
+ "block_key": block["key"],
534
+ "members": [ac],
535
+ "canonical_name": variants[0] if variants else None,
536
+ "confidence": 1.0,
537
+ "source": "deterministic",
538
+ "tier": 1,
539
+ "reason": "High string similarity (automatic)",
540
+ })
541
+ auto_count += 1
542
+ if auto_count:
543
+ ctx.log(f"Tier 1 (automatic, no LLM): {auto_count} proposals")
544
+
545
+ # Tier 2 — yalnız belirsiz blokları LLM'e
546
+ llm_blocks = [b for b in blocks if b.get("members")]
547
+ ctx.log(f"Tier 2 (LLM review): {len(llm_blocks)} blocks")
548
+ if llm_blocks:
549
+ client = _client()
550
+ cache = DisambiguationCache(project_dir)
551
+ try:
552
+ for i, block in enumerate(llm_blocks):
553
+ if ctx.cancelled:
554
+ break
555
+ items, by_label = _label_for_llm(block["members"], "variants")
556
+ payload = {"task": "affiliation_normalization", "candidates": items}
557
+ cache_key = cache.hash_key(payload)
558
+ cached = cache.get(cache_key)
559
+ if cached:
560
+ resp = cached
561
+ else:
562
+ try:
563
+ resp = await client.chat_json_async(AFFILIATION_SYSTEM_PROMPT, payload)
564
+ cache.set(cache_key, resp)
565
+ except Exception as e:
566
+ ctx.log(f"[{i+1}/{len(llm_blocks)}] ERROR: {e}")
567
+ continue
568
+ for c in resp.get("clusters", []):
569
+ c["block_key"] = block["key"]
570
+ c["cluster_id"] = f"{block['key']}__{c.get('cluster_id', 'c')}" # global benzersiz (bloklar arası c1/c2 çakışmasın)
571
+ c["source"] = "llm"
572
+ c["tier"] = 2
573
+ c["members"] = _members_from_labels(c, by_label, "variants")
574
+ if len(_distinct_variants(c)) < 2:
575
+ continue # tek varyant — birleştirilecek bir şey yok, boş kart olmasın
576
+ all_clusters.append(c)
577
+ for u in resp.get("uncertain", []):
578
+ u["block_key"] = block["key"]
579
+ all_uncertain.append(u)
580
+ ctx.progress(0.15 + 0.8 * ((i + 1) / len(llm_blocks)))
581
+ finally:
582
+ cache.close()
583
+
584
+ proposals_path = project_dir / "disambiguation_affiliations.json"
585
+ proposals = {
586
+ "kind": "affiliations",
587
+ "generated_at": time.time(),
588
+ "clusters": all_clusters,
589
+ "uncertain": all_uncertain,
590
+ }
591
+ proposals_path.write_text(json.dumps(proposals, ensure_ascii=False, indent=2), encoding="utf-8")
592
+ ctx.log(f"{len(all_clusters)} proposals saved ({auto_count} automatic)")
593
+ ctx.progress(1.0)
594
+ return {
595
+ "task": "affiliations",
596
+ "clusters_proposed": len(all_clusters),
597
+ "auto": auto_count,
598
+ "uncertain": len(all_uncertain),
599
+ }
600
+
601
+
602
+ async def _run_cluster_tool(
603
+ ctx: JobContext, project_id: str, *,
604
+ kind: str, blocks_builder, system_prompt: str, task: str,
605
+ tier1_reason: str, mode: str = "auto", max_records: int | None = None,
606
+ ) -> dict[str, Any]:
607
+ """Affiliation-deseni standartlaştırma çalıştırıcısı (ülke / org rollup için ortak)."""
608
+ project_dir = _project_dir(project_id)
609
+ df = await asyncio.to_thread(_load_merged, project_id)
610
+ ctx.log(f"Loaded: {len(df)} records")
611
+ if max_records and max_records > 0 and len(df) > max_records:
612
+ df = df.head(max_records)
613
+ ctx.progress(0.05)
614
+
615
+ blocks = await run_cpu(
616
+ blocks_builder, df,
617
+ settings.disambiguation_blocking_threshold, mode,
618
+ settings.disambiguation_auto_approve_threshold,
619
+ should_cancel=lambda: ctx.cancelled,
620
+ )
621
+ ctx.log(f"{len(blocks)} candidate blocks")
622
+ ctx.progress(0.15)
623
+
624
+ all_clusters: list[dict] = []
625
+ all_uncertain: list[dict] = []
626
+ auto_count = 0
627
+ for block in blocks:
628
+ for ac in block.get("auto", []):
629
+ variants = ac.get("variants", [])
630
+ all_clusters.append({
631
+ "cluster_id": ac["id"],
632
+ "block_key": block["key"],
633
+ "members": [ac],
634
+ "canonical_name": ac.get("canonical_name") or (variants[0] if variants else None),
635
+ "confidence": 1.0,
636
+ "source": "deterministic",
637
+ "tier": 1,
638
+ "reason": tier1_reason,
639
+ })
640
+ auto_count += 1
641
+ if auto_count:
642
+ ctx.log(f"Tier 1 (automatic): {auto_count} proposals")
643
+
644
+ llm_blocks = [b for b in blocks if b.get("members")]
645
+ if llm_blocks:
646
+ client = _client()
647
+ cache = DisambiguationCache(project_dir)
648
+ try:
649
+ for i, block in enumerate(llm_blocks):
650
+ if ctx.cancelled:
651
+ break
652
+ items, by_label = _label_for_llm(block["members"], "variants")
653
+ payload = {"task": task, "candidates": items}
654
+ ck = cache.hash_key(payload)
655
+ cached = cache.get(ck)
656
+ if cached:
657
+ resp = cached
658
+ else:
659
+ try:
660
+ resp = await client.chat_json_async(system_prompt, payload)
661
+ cache.set(ck, resp)
662
+ except Exception as e:
663
+ ctx.log(f"[{i+1}/{len(llm_blocks)}] ERROR: {e}")
664
+ continue
665
+ for c in resp.get("clusters", []):
666
+ c["block_key"] = block["key"]
667
+ c["cluster_id"] = f"{block['key']}__{c.get('cluster_id', 'c')}" # global benzersiz (bloklar arası c1/c2 çakışmasın)
668
+ c["source"] = "llm"
669
+ c["tier"] = 2
670
+ c["members"] = _members_from_labels(c, by_label, "variants")
671
+ # Country'de tek token geçerlidir (bir ülke yazımını normalize et veya
672
+ # bir kuruma ülke ekle); diğerlerinde (org) birleştirme için >=2 gerekir.
673
+ if kind != "countries" and len(_distinct_variants(c)) < 2:
674
+ continue # tek varyant — birleştirilecek bir şey yok, boş kart olmasın
675
+ all_clusters.append(c)
676
+ for u in resp.get("uncertain", []):
677
+ u["block_key"] = block["key"]
678
+ all_uncertain.append(u)
679
+ ctx.progress(0.15 + 0.8 * ((i + 1) / len(llm_blocks)))
680
+ finally:
681
+ cache.close()
682
+
683
+ (project_dir / f"disambiguation_{kind}.json").write_text(
684
+ json.dumps({"kind": kind, "generated_at": time.time(), "clusters": all_clusters, "uncertain": all_uncertain},
685
+ ensure_ascii=False, indent=2), encoding="utf-8")
686
+ ctx.progress(1.0)
687
+ ctx.log(f"{len(all_clusters)} proposals saved ({auto_count} automatic)")
688
+ return {"task": kind, "clusters_proposed": len(all_clusters), "auto": auto_count, "uncertain": len(all_uncertain)}
689
+
690
+
691
+ async def run_country_standardization(ctx: JobContext, project_id: str, mode: str = "auto", max_records: int | None = None) -> dict[str, Any]:
692
+ return await _run_cluster_tool(
693
+ ctx, project_id, kind="countries", blocks_builder=build_country_blocks,
694
+ system_prompt=COUNTRY_SYSTEM_PROMPT, task="country_standardization",
695
+ tier1_reason="Dictionary match (automatic)", mode=mode, max_records=max_records,
696
+ )
697
+
698
+
699
+ async def run_org_rollup(ctx: JobContext, project_id: str, mode: str = "auto", max_records: int | None = None) -> dict[str, Any]:
700
+ return await _run_cluster_tool(
701
+ ctx, project_id, kind="organizations", blocks_builder=build_org_rollup,
702
+ system_prompt=ORG_ROLLUP_SYSTEM_PROMPT, task="org_rollup",
703
+ tier1_reason="Address parse -> parent institution (automatic)", mode=mode, max_records=max_records,
704
+ )
705
+
706
+
707
+ def list_proposals(project_id: str, kind: str) -> dict[str, Any]:
708
+ project_dir = _project_dir(project_id)
709
+ fname = f"disambiguation_{kind}.json"
710
+ p = project_dir / fname
711
+ if not p.exists():
712
+ return {"kind": kind, "clusters": [], "uncertain": []}
713
+ return json.loads(p.read_text(encoding="utf-8"))
714
+
715
+
716
+ def apply_clusters(project_id: str, kind: str, approved: list[dict]) -> dict[str, Any]:
717
+ """Onaylanmış cluster'ları orijinal datasete uygula.
718
+
719
+ approved: list of {member_ids, canonical?: str}
720
+ """
721
+ project_dir = _project_dir(project_id)
722
+ src = merger.merged_dataset_path(project_id)
723
+ if src is None:
724
+ raise HTTPException(409, "no_merged_data")
725
+ df = pd.read_excel(src)
726
+
727
+ # NOT: snapshot işin SONUNDA, yalnız gerçekten değişiklik yapılacaksa (affected>0)
728
+ # alınır — 'sıfır değişiklik' apply'ları boş snapshot çöplüğü üretmesin.
729
+
730
+ # Proposal listesinden member metadata'sını oku
731
+ proposals = list_proposals(project_id, kind)
732
+ cluster_lookup = {c.get("cluster_id"): c for c in proposals.get("clusters", [])}
733
+
734
+ affected = 0
735
+ if kind == "authors":
736
+ # Onay: her cluster için canonical bir isim seç (varsayılan: ilk variant) ve diğerlerini değiştir
737
+ if "AU" not in df.columns:
738
+ raise HTTPException(400, "no_au_column")
739
+ for entry in approved:
740
+ cid = entry.get("cluster_id")
741
+ cluster = cluster_lookup.get(cid)
742
+ if not cluster:
743
+ continue
744
+ # Tüm name_variants'i topla (boşsa üyenin id'sine düş — LLM stub'ları)
745
+ all_variants: list[str] = []
746
+ for m in cluster.get("members", []):
747
+ all_variants.extend(_member_variants(m, "name_variants"))
748
+ canonical = entry.get("canonical") or (all_variants[0] if all_variants else None)
749
+ if not canonical:
750
+ continue
751
+ import re as _re
752
+
753
+ def _nkey(s: str) -> str:
754
+ return _re.sub(r"\s+", " ", str(s or "")).strip().lower()
755
+
756
+ # Boşluk-toleranslı: WoS AU yazımı tutarsız olabilir ('GAO S' vs 'GAO S').
757
+ variant_keys = {_nkey(v) for v in all_variants if _nkey(v) != _nkey(canonical)}
758
+ if not variant_keys:
759
+ continue
760
+
761
+ def _repl(s: str) -> str:
762
+ return ";".join(canonical if _nkey(x) in variant_keys else x.strip() for x in str(s).split(";"))
763
+
764
+ before_col = df["AU"].astype(str)
765
+ df["AU"] = before_col.apply(_repl)
766
+ affected += int((df["AU"] != before_col).sum())
767
+
768
+ elif kind == "affiliations":
769
+ cols = [c for c in ("C1", "C1raw") if c in df.columns]
770
+ if not cols:
771
+ raise HTTPException(400, "no_c1_column")
772
+ from bibex_core.modules.c1_utils import replace_affiliation_in_c1
773
+ mapping: dict[str, str] = {} # varyant affiliation -> kanonik (bileşen-tam)
774
+ for entry in approved:
775
+ cluster = cluster_lookup.get(entry.get("cluster_id"))
776
+ if not cluster:
777
+ continue
778
+ all_variants: list[str] = []
779
+ for m in cluster.get("members", []):
780
+ all_variants.extend(_member_variants(m, "variants"))
781
+ canonical = entry.get("canonical") or cluster.get("canonical_name") or (all_variants[0] if all_variants else None)
782
+ if not canonical:
783
+ continue
784
+ for variant in set(all_variants):
785
+ if variant and variant != canonical:
786
+ mapping[variant] = canonical
787
+ if mapping:
788
+ affected = _apply_c1_map(df, cols, lambda s: replace_affiliation_in_c1(s, mapping))
789
+
790
+ elif kind == "countries":
791
+ cols = [c for c in ("C1", "C1raw") if c in df.columns]
792
+ if not cols:
793
+ raise HTTPException(400, "no_c1_column")
794
+ from bibex_core.modules.c1_utils import replace_country_in_c1, _is_org
795
+ mapping: dict[str, str] = {} # küçük-harf ülke-varyantı -> kanonik ülke
796
+ for entry in approved:
797
+ cluster = cluster_lookup.get(entry.get("cluster_id"))
798
+ if not cluster:
799
+ continue
800
+ canon = entry.get("canonical") or cluster.get("canonical_name")
801
+ if not canon:
802
+ continue
803
+ for m in cluster.get("members", []):
804
+ for v in _member_variants(m, "variants"):
805
+ tok = str(v).strip()
806
+ # GÜVENLİK: kurum tokeni ülke replace'ine girmesin (kurum=ülke karmasını
807
+ # ve C1 bozulmasını önler). Country standardizasyonu YALNIZ ülke yazımını
808
+ # normalize eder; eksik ülke tamamlama ayrı API eylemiyle yapılır.
809
+ if tok and not _is_org(tok):
810
+ mapping[tok.lower()] = canon
811
+ if mapping:
812
+ affected = _apply_c1_map(df, cols, lambda s: replace_country_in_c1(s, mapping))
813
+
814
+ elif kind == "organizations":
815
+ cols = [c for c in ("C1", "C1raw") if c in df.columns]
816
+ if not cols:
817
+ raise HTTPException(400, "no_c1_column")
818
+ from bibex_core.modules.c1_utils import replace_org_in_c1
819
+ mapping: dict[str, str] = {} # tam adres varyantı -> kanonik üst kurum
820
+ for entry in approved:
821
+ cluster = cluster_lookup.get(entry.get("cluster_id"))
822
+ if not cluster:
823
+ continue
824
+ canon = entry.get("canonical") or cluster.get("canonical_name")
825
+ if not canon:
826
+ continue
827
+ for m in cluster.get("members", []):
828
+ for v in _member_variants(m, "variants"):
829
+ if v and str(v).strip():
830
+ mapping[str(v).strip()] = canon
831
+ if mapping:
832
+ affected = _apply_c1_map(df, cols, lambda s: replace_org_in_c1(s, mapping))
833
+ else:
834
+ raise HTTPException(400, f"Bilinmeyen kind: {kind}")
835
+
836
+ # Kaydet (orijinal merged dosyanın üstüne yaz, ama snapshot var)
837
+ # Hiçbir değişiklik yapılmadıysa: snapshot/yazım/audit YOK (boş apply no-op).
838
+ if affected == 0:
839
+ return {"kind": kind, "approved_count": len(approved), "replacements": 0, "snapshot": None}
840
+
841
+ # Değişiklik var → ÖNCE snapshot (df'in güncel hâli yazımdan önce yedeklenir), sonra yaz.
842
+ snap = _snapshot(project_dir, pd.read_excel(src), kind)
843
+ df.to_excel(src, index=False)
844
+ # Filter cache temizle
845
+ from services.filter_engine import _DF_CACHE
846
+ _DF_CACHE.clear()
847
+
848
+ # Audit log
849
+ audit_path = project_dir / f"disambiguation_audit_{kind}.json"
850
+ audit: list[dict] = []
851
+ if audit_path.exists():
852
+ audit = json.loads(audit_path.read_text(encoding="utf-8"))
853
+ audit.append({
854
+ "applied_at": time.time(),
855
+ "snapshot": str(snap.relative_to(storage.settings.storage_path)),
856
+ "approved_count": len(approved),
857
+ "replacements": affected,
858
+ "details": approved,
859
+ })
860
+ audit_path.write_text(json.dumps(audit, ensure_ascii=False, indent=2), encoding="utf-8")
861
+
862
+ # Onaylanan cluster'ları proposals'tan düş — uygulananlar listede tekrar görünmesin.
863
+ applied_ids = {e.get("cluster_id") for e in approved if e.get("cluster_id")}
864
+ if applied_ids:
865
+ props = list_proposals(project_id, kind)
866
+ props["clusters"] = [c for c in props.get("clusters", []) if c.get("cluster_id") not in applied_ids]
867
+ (project_dir / f"disambiguation_{kind}.json").write_text(
868
+ json.dumps(props, ensure_ascii=False, indent=2), encoding="utf-8",
869
+ )
870
+
871
+ return {
872
+ "kind": kind,
873
+ "approved_count": len(approved),
874
+ "replacements": affected,
875
+ "snapshot": str(snap.relative_to(storage.settings.storage_path)),
876
+ }
877
+
878
+
879
+ def apply_splits(project_id: str, approved: list[dict]) -> dict[str, Any]:
880
+ """Onaylanan AYRIŞTIRMA önerilerini uygula — ilgili kayıtlarda yazar adına
881
+ '(b)' '(c)' eki ekleyerek düğümleri ayır. En büyük grup (suffix boş) dokunulmaz.
882
+
883
+ approved: split önerileri (split_id ile) veya tam obje (name + groups[]).
884
+ """
885
+ project_dir = _project_dir(project_id)
886
+ src = merger.merged_dataset_path(project_id)
887
+ if src is None:
888
+ raise HTTPException(409, "no_merged_data")
889
+ df = pd.read_excel(src)
890
+ if "AU" not in df.columns:
891
+ raise HTTPException(400, "no_au_column")
892
+
893
+ # snapshot işin SONUNDA, yalnız affected>0 ise alınır (boş apply no-op).
894
+ proposals = list_proposals(project_id, "authors")
895
+ lookup = {s.get("split_id"): s for s in proposals.get("splits", [])}
896
+
897
+ import re as _re
898
+
899
+ def _nkey(s: str) -> str:
900
+ # Boşluk-normalize anahtar: WoS AU yazımı tutarsız olabilir ('GAO S' vs 'GAO S').
901
+ return _re.sub(r"\s+", " ", str(s or "")).strip().lower()
902
+
903
+ affected = 0
904
+ for entry in approved:
905
+ sp = lookup.get(entry.get("split_id")) or entry # tam obje de kabul
906
+ name = sp.get("name")
907
+ if not name:
908
+ continue
909
+ name_key = _nkey(name)
910
+ for g in sp.get("groups", []):
911
+ suffix = (g.get("suffix") or "").strip()
912
+ if not suffix:
913
+ continue # düz grup — dokunma
914
+ new_name = f"{name} {suffix}"
915
+ for rec in g.get("records", []):
916
+ if not isinstance(rec, int) or rec < 0 or rec >= len(df):
917
+ continue
918
+ cell = df.at[rec, "AU"]
919
+ cur = "" if pd.isna(cell) else str(cell)
920
+ # Boşluk-toleranslı token eşleşmesi (ham AU yazımı korunur, yalnız
921
+ # eşleşen token yeni adla değiştirilir).
922
+ new_parts, hit = [], False
923
+ for x in cur.split(";"):
924
+ if _nkey(x) == name_key:
925
+ new_parts.append(new_name)
926
+ hit = True
927
+ else:
928
+ new_parts.append(x.strip())
929
+ if hit:
930
+ df.at[rec, "AU"] = ";".join(new_parts)
931
+ affected += 1
932
+
933
+ if affected == 0:
934
+ return {"kind": "authors_split", "approved_count": len(approved), "replacements": 0, "snapshot": None}
935
+
936
+ snap = _snapshot(project_dir, pd.read_excel(src), "authors_split")
937
+ df.to_excel(src, index=False)
938
+ try:
939
+ from services.filter_engine import _DF_CACHE
940
+ _DF_CACHE.clear()
941
+ except Exception:
942
+ pass
943
+
944
+ audit_path = project_dir / "disambiguation_audit_authors_split.json"
945
+ log: list[dict] = []
946
+ if audit_path.exists():
947
+ log = json.loads(audit_path.read_text(encoding="utf-8"))
948
+ log.append({
949
+ "applied_at": time.time(),
950
+ "snapshot": str(snap.relative_to(storage.settings.storage_path)),
951
+ "approved_count": len(approved),
952
+ "replacements": affected,
953
+ })
954
+ audit_path.write_text(json.dumps(log, ensure_ascii=False, indent=2), encoding="utf-8")
955
+
956
+ # Onaylanan split'leri proposals'tan düş — uygulananlar listede tekrar görünmesin.
957
+ applied_ids = {e.get("split_id") for e in approved if e.get("split_id")}
958
+ if applied_ids:
959
+ props = list_proposals(project_id, "authors")
960
+ props["splits"] = [s for s in props.get("splits", []) if s.get("split_id") not in applied_ids]
961
+ (project_dir / "disambiguation_authors.json").write_text(
962
+ json.dumps(props, ensure_ascii=False, indent=2), encoding="utf-8",
963
+ )
964
+
965
+ return {
966
+ "kind": "authors_split",
967
+ "approved_count": len(approved),
968
+ "replacements": affected,
969
+ "snapshot": str(snap.relative_to(storage.settings.storage_path)),
970
+ }
971
+
972
+
973
+ def restore_snapshot(project_id: str, snapshot_relative: str) -> dict[str, Any]:
974
+ project_dir = _project_dir(project_id)
975
+ snap_path = storage.settings.storage_path / snapshot_relative
976
+ if not snap_path.exists() or project_dir not in snap_path.parents:
977
+ raise HTTPException(404, "snapshot_not_found")
978
+ target = merger.merged_dataset_path(project_id)
979
+ if target is None:
980
+ raise HTTPException(409, "no_merged_data")
981
+ import shutil
982
+ shutil.copy2(snap_path, target)
983
+ from services.filter_engine import _DF_CACHE
984
+ _DF_CACHE.clear()
985
+ return {"restored_from": snapshot_relative, "into": str(target.relative_to(storage.settings.storage_path))}