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,967 @@
1
+ """Smart Merge — BibexPy v2 birleştirme algoritması (3. seçenek).
2
+
3
+ bibex_core'a (`merge_db_sources`) dokunmadan, kendi pipeline'ını yürüten yeni
4
+ servis modülü. Tasarım dokümanı: `docs/smart_merge_algorithm_design.docx`.
5
+
6
+ 6-fazlı pipeline:
7
+ 1. Normalize : DOI, title, year, surname, ISSN/PMID/UT
8
+ 2. Block : (year, surname[0]) bloklama
9
+ 3. Match : Stage 0-5 (negative rules → DOI → PMID/UT → JW title → J+V+P → borderline)
10
+ 4. Field merge: Caputo 2024 sabit defaults (TC/CR→WoS, AB/AU→Scopus, DE/ID→union, ...)
11
+ 5. Audit : match_audit.xlsx, conflict_log.xlsx, borderline_queue.xlsx
12
+ 6. Borderline : Manuel onay UI (Merge sayfası) + opsiyonel LLM (feature flag)
13
+
14
+ Akademik temeller:
15
+ - Fellegi & Sunter (1969) — olasılıksal kayıt eşleştirme
16
+ - Hammerton et al. (2013) — kural tabanlı hiyerarşi + negative rules
17
+ - Jaro (1989) / Winkler (1990) — JW benzerlik metriği
18
+ - Caputo (2024) — Scopus+WoS field preferences
19
+ - Borissov et al. (2022) — Deduklick explainable
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import json
26
+ import re
27
+ import time
28
+ import unicodedata
29
+ from pathlib import Path
30
+ from typing import Any, Optional
31
+
32
+ import pandas as pd
33
+
34
+ from config import settings
35
+ from jobs.runner import JobContext
36
+ from services import analyses, audit, filter_engine, storage
37
+ from services.disambiguation.similarity import jaro_winkler, name_initials, normalize_name
38
+
39
+
40
+ # ════════════════════════════════════════════════════════════════════════
41
+ # SABİTLER — Caputo (2024) field preferences
42
+ # ════════════════════════════════════════════════════════════════════════
43
+
44
+ # Reference: Caputo, A., Pizzi, S., Pellegrini, M. M., & Dabić, M. (2024).
45
+ # Automatic Merging of Scopus and Web of Science Data for Simplified and
46
+ # Effective Bibliometric Analysis. Annals of Data Science, 11(3), 1023–1047.
47
+ #
48
+ # Bu kurallar SABIT — UI'dan değiştirilemez. Akademik reproducibility için.
49
+ # Her dataset üzerinde Smart Merge'in v1.0 sürümü aynı sonucu üretmelidir.
50
+ FIELD_PREFERENCES: dict[str, str] = {
51
+ # WoS-öncelikli (atıf metrikleri ve referans listeleri WoS'ta daha temiz)
52
+ "TC": "wos", # times cited
53
+ "CR": "wos", # cited references
54
+ "NR": "wos", # number of references
55
+ # Scopus-öncelikli (özet, yazar listeleri Scopus'ta daha tam)
56
+ "AB": "scopus", # abstract
57
+ "AU": "scopus", # author short
58
+ "AF": "scopus", # author full
59
+ "C1": "scopus", # affiliations
60
+ # Union (her ikisinin birleşimi, ; ile dedup'lu)
61
+ "DE": "union", # author keywords
62
+ "ID": "union", # keywords plus
63
+ # Cross-fill (biri boşsa diğerinden doldur)
64
+ "WC": "cross_fill_wos_first",
65
+ "SC": "cross_fill_wos_first",
66
+ }
67
+ DEFAULT_PREFERENCE = "wos_first" # diğer tüm alanlar için
68
+
69
+ # Eşik sabitleri
70
+ TITLE_EXACT_THRESHOLD = 0.92 # Stage 3
71
+ TITLE_BORDERLINE_LOW = 0.80 # Stage 5 alt sınır
72
+ YEAR_TOLERANCE = 1 # ±1 yıl
73
+ JOURNAL_SIMILARITY = 0.90 # Stage 4
74
+
75
+ # Title normalize için stopwords
76
+ STOPWORDS: set[str] = {
77
+ "the", "a", "an", "of", "in", "on", "and", "or", "for", "to", "with",
78
+ "by", "from", "as", "at", "is", "are", "was", "were", "be", "been",
79
+ }
80
+
81
+
82
+ # ════════════════════════════════════════════════════════════════════════
83
+ # FAZ 1 — Normalize fonksiyonları
84
+ # ════════════════════════════════════════════════════════════════════════
85
+
86
+ _DOI_PREFIX_RE = re.compile(r"^https?://(dx\.)?doi\.org/", re.IGNORECASE)
87
+ _PUNCT_RE = re.compile(r"[^a-z0-9 ]+")
88
+ _WS_RE = re.compile(r"\s+")
89
+ _LATEX_RE = re.compile(r"\\[a-z]+\{[^}]*\}|\\[\\\\&%$#_{}~^]")
90
+ _ISSN_RE = re.compile(r"[^0-9Xx]")
91
+
92
+
93
+ def _to_str(v: Any) -> str:
94
+ if v is None:
95
+ return ""
96
+ try:
97
+ if pd.isna(v):
98
+ return ""
99
+ except (TypeError, ValueError):
100
+ pass
101
+ return str(v).strip()
102
+
103
+
104
+ def normalize_doi(raw: Any) -> Optional[str]:
105
+ """Bir DOI'yi kanonik biçime indir.
106
+
107
+ Örnekler:
108
+ 'https://doi.org/10.1234/ABC' → '10.1234/abc'
109
+ 'http://dx.doi.org/10.x' → '10.x'
110
+ '10.1234/abc/' → '10.1234/abc'
111
+ """
112
+ s = _to_str(raw)
113
+ if not s:
114
+ return None
115
+ s = s.lower()
116
+ s = _DOI_PREFIX_RE.sub("", s)
117
+ s = s.rstrip("/. \t")
118
+ if not s.startswith("10."):
119
+ return None
120
+ return s
121
+
122
+
123
+ def normalize_title(raw: Any) -> str:
124
+ """Title'ı kanonik forma indir: Unicode NFKD → ASCII → lower → punct strip → stopwords."""
125
+ s = _to_str(raw)
126
+ if not s:
127
+ return ""
128
+ # LaTeX makro temizliği
129
+ s = _LATEX_RE.sub(" ", s)
130
+ # Unicode normalize
131
+ s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("ascii")
132
+ s = s.lower()
133
+ s = _PUNCT_RE.sub(" ", s)
134
+ s = _WS_RE.sub(" ", s).strip()
135
+ # Stopword removal
136
+ tokens = [t for t in s.split() if t not in STOPWORDS]
137
+ return " ".join(tokens)
138
+
139
+
140
+ def normalize_year(raw: Any) -> Optional[int]:
141
+ """'2023.0' / '2023' / 2023 → 2023; geçersizler → None."""
142
+ s = _to_str(raw)
143
+ if not s:
144
+ return None
145
+ try:
146
+ n = int(float(s))
147
+ if 1900 <= n <= 2100:
148
+ return n
149
+ return None
150
+ except (ValueError, TypeError):
151
+ return None
152
+
153
+
154
+ def normalize_author_surname(raw: Any) -> str:
155
+ """İlk yazarın soyadını uppercase ASCII normalize et.
156
+
157
+ WoS 'Smith J;Lee K' veya Scopus 'Smith, John A.;Lee, Kim' her ikisi 'SMITH'.
158
+ Boş ise '' döner.
159
+ """
160
+ s = _to_str(raw)
161
+ if not s:
162
+ return ""
163
+ # İlk yazar
164
+ first = re.split(r"[;|]", s, maxsplit=1)[0]
165
+ # Scopus formatı: 'Smith, John A.' → 'Smith John A'
166
+ first = first.replace(",", " ")
167
+ surname, _initials = name_initials(first)
168
+ return surname.upper()
169
+
170
+
171
+ def normalize_issn(raw: Any) -> Optional[str]:
172
+ """ISSN'i 8 hane (rakam + X) forma indir; geçersiz → None."""
173
+ s = _to_str(raw)
174
+ if not s:
175
+ return None
176
+ s = _ISSN_RE.sub("", s.upper())
177
+ if len(s) == 8:
178
+ return s
179
+ return None
180
+
181
+
182
+ def normalize_id_token(raw: Any) -> Optional[str]:
183
+ """PMID / UT gibi genel token normalize: lowercase, whitespace strip."""
184
+ s = _to_str(raw)
185
+ if not s:
186
+ return None
187
+ s = s.lower().strip()
188
+ return s or None
189
+
190
+
191
+ # ════════════════════════════════════════════════════════════════════════
192
+ # FAZ 2 — Blocking
193
+ # ════════════════════════════════════════════════════════════════════════
194
+
195
+ def build_blocks(df: pd.DataFrame) -> dict[tuple[Optional[int], str], list[int]]:
196
+ """key = (year, surname[0]) → satır indeksleri listesi.
197
+
198
+ Year None ise (None, surname[0]) bloğuna düşer.
199
+ Surname boş ise (year, '') bloğuna düşer.
200
+ """
201
+ blocks: dict[tuple[Optional[int], str], list[int]] = {}
202
+ for idx, row in df.iterrows():
203
+ year = row.get("_norm_year")
204
+ surname = row.get("_norm_surname", "")
205
+ first_letter = surname[0] if surname else ""
206
+ key = (year, first_letter)
207
+ blocks.setdefault(key, []).append(int(idx))
208
+ return blocks
209
+
210
+
211
+ # ════════════════════════════════════════════════════════════════════════
212
+ # FAZ 3 — Multi-stage matching
213
+ # ════════════════════════════════════════════════════════════════════════
214
+
215
+ def negative_rule_check(w: dict, s: dict) -> Optional[str]:
216
+ """Hammerton (2013): PMID/ISSN birbiriyle çelişiyorsa → reject.
217
+
218
+ Both sides have value AND they're different → reject (kayıtlar farklı yayın).
219
+ Hiçbir tarafta yoksa, sessizce geç.
220
+
221
+ NOT: UT/EID cross-database aynı değildir (WoS UT 'WOS:xxx', Scopus EID '2-s2.0-xxx').
222
+ Bu yüzden UT negative rule olarak KULLANILMAZ. Sadece aynı kaynak içi olur.
223
+ PMID ise cross-database aynıdır (PubMed Identifier).
224
+ """
225
+ for key in ("_norm_pmid", "_norm_issn"):
226
+ wv = w.get(key)
227
+ sv = s.get(key)
228
+ if wv and sv and wv != sv:
229
+ return f"{key.replace('_norm_', '').upper()} mismatch ({wv} ≠ {sv})"
230
+ return None
231
+
232
+
233
+ def compute_match(w: dict, s: dict) -> Optional[dict]:
234
+ """5-aşamalı eşleştirme kararı.
235
+
236
+ Dönüş: None (no match) veya {stage, confidence, reason, jw_title, year_diff, surname_match}
237
+ """
238
+ # Stage 0 — Negative rules (reject)
239
+ neg = negative_rule_check(w, s)
240
+ if neg:
241
+ return None
242
+
243
+ # Stage 1 — DOI exact
244
+ w_doi = w.get("_norm_doi")
245
+ s_doi = s.get("_norm_doi")
246
+ if w_doi and s_doi and w_doi == s_doi:
247
+ return {
248
+ "stage": "1_doi_exact",
249
+ "stage_label": "DOI exact",
250
+ "confidence": 1.00,
251
+ "reason": f"DOI exact: {w_doi}",
252
+ "jw_title": None,
253
+ "year_diff": None,
254
+ "surname_match": None,
255
+ }
256
+
257
+ # Stage 2 — PMID exact (UT cross-database aynı değildir, sadece PMID)
258
+ w_pmid = w.get("_norm_pmid")
259
+ s_pmid = s.get("_norm_pmid")
260
+ if w_pmid and s_pmid and w_pmid == s_pmid:
261
+ return {
262
+ "stage": "2_pmid_exact",
263
+ "stage_label": "PMID exact",
264
+ "confidence": 0.99,
265
+ "reason": f"PMID exact: {w_pmid}",
266
+ "jw_title": None,
267
+ "year_diff": None,
268
+ "surname_match": None,
269
+ }
270
+
271
+ # Stage 3 — Title JW ≥ 0.92 + Year ±1 + Surname match
272
+ w_title = w.get("_norm_title", "")
273
+ s_title = s.get("_norm_title", "")
274
+ if w_title and s_title:
275
+ jw_title = jaro_winkler(w_title, s_title)
276
+ w_year = w.get("_norm_year")
277
+ s_year = s.get("_norm_year")
278
+ year_diff = abs((w_year or 0) - (s_year or 0)) if (w_year is not None and s_year is not None) else None
279
+ w_surname = w.get("_norm_surname", "")
280
+ s_surname = s.get("_norm_surname", "")
281
+ surname_match = bool(w_surname and s_surname and w_surname == s_surname)
282
+
283
+ if (
284
+ jw_title >= TITLE_EXACT_THRESHOLD
285
+ and year_diff is not None
286
+ and year_diff <= YEAR_TOLERANCE
287
+ and surname_match
288
+ ):
289
+ return {
290
+ "stage": "3_title_year_surname",
291
+ "stage_label": "Title+Year+Surname",
292
+ "confidence": 0.95,
293
+ "reason": f"JW(title)={jw_title:.3f} ≥ {TITLE_EXACT_THRESHOLD}, year_diff={year_diff}, surname='{w_surname}' eşleşti",
294
+ "jw_title": round(jw_title, 4),
295
+ "year_diff": year_diff,
296
+ "surname_match": surname_match,
297
+ }
298
+
299
+ # Stage 4 — Journal + Volume + (Pages veya BP) — DOI'siz eski yayınlar için
300
+ w_journal = w.get("_norm_journal", "")
301
+ s_journal = s.get("_norm_journal", "")
302
+ if w_journal and s_journal:
303
+ jw_journal = jaro_winkler(w_journal, s_journal)
304
+ w_vol = _to_str(w.get("VL", ""))
305
+ s_vol = _to_str(s.get("VL", ""))
306
+ w_bp = _to_str(w.get("BP", ""))
307
+ s_bp = _to_str(s.get("BP", ""))
308
+ w_pg = _to_str(w.get("PG", ""))
309
+ s_pg = _to_str(s.get("PG", ""))
310
+ page_match = (w_bp and s_bp and w_bp == s_bp) or (w_pg and s_pg and w_pg == s_pg)
311
+ if (
312
+ jw_journal >= JOURNAL_SIMILARITY
313
+ and w_vol and s_vol and w_vol == s_vol
314
+ and page_match
315
+ ):
316
+ return {
317
+ "stage": "4_journal_vol_page",
318
+ "stage_label": "Journal+Vol+Pages",
319
+ "confidence": 0.90,
320
+ "reason": f"JW(journal)={jw_journal:.3f}, vol={w_vol}, page_match=True",
321
+ "jw_title": round(jw_title, 4),
322
+ "year_diff": year_diff,
323
+ "surname_match": surname_match,
324
+ }
325
+
326
+ # Stage 5 — Borderline (manual queue)
327
+ if TITLE_BORDERLINE_LOW <= jw_title < TITLE_EXACT_THRESHOLD:
328
+ # Confidence linear scale 0.70-0.85
329
+ conf = 0.70 + (jw_title - TITLE_BORDERLINE_LOW) * (0.85 - 0.70) / (TITLE_EXACT_THRESHOLD - TITLE_BORDERLINE_LOW)
330
+ return {
331
+ "stage": "5_borderline",
332
+ "stage_label": "Borderline (manual review)",
333
+ "confidence": round(conf, 3),
334
+ "reason": f"JW(title)={jw_title:.3f}, year_diff={year_diff}, surname_match={surname_match}",
335
+ "jw_title": round(jw_title, 4),
336
+ "year_diff": year_diff,
337
+ "surname_match": surname_match,
338
+ }
339
+
340
+ return None
341
+
342
+
343
+ # ════════════════════════════════════════════════════════════════════════
344
+ # FAZ 4 — Field merge with Caputo 2024 preferences
345
+ # ════════════════════════════════════════════════════════════════════════
346
+
347
+ def _is_empty(v: Any) -> bool:
348
+ if v is None:
349
+ return True
350
+ try:
351
+ if pd.isna(v):
352
+ return True
353
+ except (TypeError, ValueError):
354
+ pass
355
+ s = str(v).strip()
356
+ return s == "" or s.lower() == "nan"
357
+
358
+
359
+ def _union_values(w_val: Any, s_val: Any, sep: str = "; ") -> str:
360
+ """Iki değeri ; ile birleştirip dedup et (case-insensitive)."""
361
+ parts: list[str] = []
362
+ seen: set[str] = set()
363
+ for v in (w_val, s_val):
364
+ if _is_empty(v):
365
+ continue
366
+ for token in re.split(r"\s*[;|]\s*", _to_str(v)):
367
+ token = token.strip()
368
+ if not token:
369
+ continue
370
+ key = token.lower()
371
+ if key in seen:
372
+ continue
373
+ seen.add(key)
374
+ parts.append(token)
375
+ return sep.join(parts)
376
+
377
+
378
+ def _apply_preference(field: str, w_val: Any, s_val: Any) -> tuple[Any, str]:
379
+ """Bir alan için Caputo 2024 default tercihini uygula.
380
+
381
+ Dönüş: (chosen_value, chosen_source) — source in {"wos","scopus","union","cross_fill","empty"}
382
+ """
383
+ pref = FIELD_PREFERENCES.get(field, DEFAULT_PREFERENCE)
384
+ w_empty = _is_empty(w_val)
385
+ s_empty = _is_empty(s_val)
386
+
387
+ if w_empty and s_empty:
388
+ return "", "empty"
389
+
390
+ if pref == "wos":
391
+ return (w_val, "wos") if not w_empty else (s_val, "scopus_fallback")
392
+ if pref == "scopus":
393
+ return (s_val, "scopus") if not s_empty else (w_val, "wos_fallback")
394
+ if pref == "union":
395
+ return _union_values(w_val, s_val), "union"
396
+ if pref == "cross_fill_wos_first":
397
+ if not w_empty:
398
+ return w_val, "wos"
399
+ return s_val, "scopus"
400
+ if pref == "wos_first":
401
+ if not w_empty:
402
+ return w_val, "wos"
403
+ return s_val, "scopus"
404
+ # Bilinmeyen pref — default wos_first
405
+ if not w_empty:
406
+ return w_val, "wos"
407
+ return s_val, "scopus"
408
+
409
+
410
+ def merge_pair_with_preferences(
411
+ pair_id: str,
412
+ w: dict,
413
+ s: dict,
414
+ all_columns: list[str],
415
+ ) -> tuple[dict, list[dict]]:
416
+ """İki kaydı birleştir, çakışmaları logla.
417
+
418
+ Dönüş: (merged_row, conflicts_list)
419
+ """
420
+ merged: dict[str, Any] = {}
421
+ conflicts: list[dict] = []
422
+
423
+ for col in all_columns:
424
+ if col.startswith("_norm_"):
425
+ continue # internal kolonlar
426
+ w_val = w.get(col)
427
+ s_val = s.get(col)
428
+ chosen, source = _apply_preference(col, w_val, s_val)
429
+ merged[col] = chosen
430
+
431
+ # Çakışma logu: iki taraf da dolu VE değerler farklı
432
+ if not _is_empty(w_val) and not _is_empty(s_val):
433
+ if _to_str(w_val).lower().strip() != _to_str(s_val).lower().strip():
434
+ conflicts.append({
435
+ "pair_id": pair_id,
436
+ "field": col,
437
+ "wos_value": _to_str(w_val)[:200],
438
+ "scopus_value": _to_str(s_val)[:200],
439
+ "chosen_source": source,
440
+ "chosen_value": _to_str(chosen)[:200],
441
+ "preference_rule": FIELD_PREFERENCES.get(col, DEFAULT_PREFERENCE),
442
+ })
443
+
444
+ # DB etiketleme — birleştirilmiş kayıt
445
+ merged["DB"] = "BIBEXPY_SMART"
446
+ merged["DB_Original"] = "ISI; SCOPUS"
447
+
448
+ return merged, conflicts
449
+
450
+
451
+ # ════════════════════════════════════════════════════════════════════════
452
+ # FAZ 5 — Yazıcılar (audit, conflict, borderline, lost, statistic)
453
+ # ════════════════════════════════════════════════════════════════════════
454
+
455
+ def _write_match_audit(rows: list[dict], out: Path) -> None:
456
+ if not rows:
457
+ return
458
+ df = pd.DataFrame(rows)
459
+ cols = ["pair_id", "wos_index", "scp_index", "doi", "stage", "stage_label",
460
+ "confidence", "jw_title", "year_diff", "surname_match", "reason"]
461
+ cols = [c for c in cols if c in df.columns]
462
+ df[cols].to_excel(out, index=False)
463
+
464
+
465
+ def _write_conflict_log(conflicts: list[dict], out: Path) -> None:
466
+ if not conflicts:
467
+ return
468
+ df = pd.DataFrame(conflicts)
469
+ df.to_excel(out, index=False)
470
+
471
+
472
+ def _write_borderline_queue(items: list[dict], out: Path) -> None:
473
+ if not items:
474
+ return
475
+ df = pd.DataFrame(items)
476
+ df.to_excel(out, index=False)
477
+
478
+
479
+ def _write_lost_records(df: pd.DataFrame, out: Path) -> None:
480
+ if df.empty:
481
+ return
482
+ # Internal _norm_* kolonlarını çıkar
483
+ cols = [c for c in df.columns if not c.startswith("_norm_")]
484
+ df[cols].to_excel(out, index=False)
485
+
486
+
487
+ def _write_statistic_smart(
488
+ total: int, wos_count: int, scp_count: int,
489
+ merged_df: pd.DataFrame, out: Path,
490
+ ) -> None:
491
+ """Mevcut Statistic.xlsx şemasıyla uyumlu — General Stats + Field Stats sheet'leri."""
492
+ general = pd.DataFrame([{
493
+ "Total Records": total,
494
+ "WoS Records": wos_count,
495
+ "Scopus Records": scp_count,
496
+ "Merged Columns": len(merged_df.columns),
497
+ "Common Columns": 0, # placeholder
498
+ }])
499
+
500
+ # Field stats
501
+ field_rows = []
502
+ for col in merged_df.columns:
503
+ if col.startswith("_norm_"):
504
+ continue
505
+ missing = int(merged_df[col].apply(_is_empty).sum())
506
+ pct = (missing / total * 100) if total else 0
507
+ if pct == 0:
508
+ status = "Excellent"
509
+ elif pct < 5:
510
+ status = "Very Good"
511
+ elif pct < 15:
512
+ status = "Good"
513
+ elif pct < 40:
514
+ status = "Poor"
515
+ else:
516
+ status = "Very Poor"
517
+ field_rows.append({
518
+ "": col,
519
+ "Description": col,
520
+ "Missing Count": missing,
521
+ "Missing %": round(pct, 2),
522
+ "Status": status,
523
+ })
524
+ fields = pd.DataFrame(field_rows)
525
+
526
+ with pd.ExcelWriter(out, engine="openpyxl") as writer:
527
+ general.to_excel(writer, sheet_name="General Stats", index=False)
528
+ fields.to_excel(writer, sheet_name="Field Stats", index=False)
529
+
530
+
531
+ # ════════════════════════════════════════════════════════════════════════
532
+ # Borderline state yönetimi (JSON)
533
+ # ════════════════════════════════════════════════════════════════════════
534
+
535
+ def _borderline_state_path(project_id: str, adir: Optional[Path] = None) -> Optional[Path]:
536
+ """Borderline state dosyasının yolu — aktif analiz klasöründe."""
537
+ if adir is None:
538
+ adir = analyses.get_active_analysis_dir(project_id)
539
+ if adir is None:
540
+ return None
541
+ return adir / "borderline_state.json"
542
+
543
+
544
+ def _read_borderline_state(project_id: str, adir: Optional[Path] = None) -> dict[str, dict]:
545
+ p = _borderline_state_path(project_id, adir)
546
+ if p is None or not p.exists():
547
+ return {}
548
+ try:
549
+ return json.loads(p.read_text(encoding="utf-8"))
550
+ except Exception:
551
+ return {}
552
+
553
+
554
+ def _write_borderline_state(project_id: str, state: dict[str, dict], adir: Optional[Path] = None) -> None:
555
+ p = _borderline_state_path(project_id, adir)
556
+ if p is None:
557
+ return
558
+ p.parent.mkdir(parents=True, exist_ok=True)
559
+ p.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
560
+
561
+
562
+ # ════════════════════════════════════════════════════════════════════════
563
+ # ANA ORKESTRATÖR
564
+ # ════════════════════════════════════════════════════════════════════════
565
+
566
+ async def run_smart_merge(ctx: JobContext, project_id: str) -> dict[str, Any]:
567
+ """Smart Merge ana iş akışı. Result dict döner (audit hook için).
568
+
569
+ Yeni bir analiz klasörü oluşturur, çıktıları oraya yazar ve sonunda
570
+ aktif analiz olarak işaretler. Hata durumunda yarım klasör temizlenir.
571
+ """
572
+
573
+ ctx.log("Starting Smart Merge...")
574
+ ctx.progress(0.02)
575
+
576
+ # 0. Yeni analiz klasörü
577
+ analysis_id, adir = analyses.create_analysis(project_id, "smart")
578
+ ctx.log(f"Analysis folder: {analysis_id}")
579
+
580
+ # 1. Veriyi yükle (merger.py'dan reuse)
581
+ from services.merger import _load_inputs
582
+
583
+ try:
584
+ scp_df, wos_df = await asyncio.to_thread(_load_inputs, project_id, ctx)
585
+ except Exception:
586
+ try:
587
+ analyses.delete_analysis(project_id, analysis_id)
588
+ except Exception:
589
+ pass
590
+ raise
591
+ ctx.log(f"Data loaded: {len(wos_df)} WoS + {len(scp_df)} Scopus")
592
+ ctx.progress(0.10)
593
+
594
+ # DB etiketle
595
+ wos_df = wos_df.copy()
596
+ scp_df = scp_df.copy()
597
+ wos_df["DB"] = "ISI"
598
+ scp_df["DB"] = "SCOPUS"
599
+
600
+ # 2. Normalize — yan kolonlar
601
+ ctx.log(f"Normalization: {len(wos_df) + len(scp_df)} rows")
602
+ for df in (wos_df, scp_df):
603
+ df["_norm_doi"] = df.get("DI", pd.Series([""] * len(df))).apply(normalize_doi)
604
+ df["_norm_title"] = df.get("TI", pd.Series([""] * len(df))).apply(normalize_title)
605
+ df["_norm_year"] = df.get("PY", pd.Series([""] * len(df))).apply(normalize_year)
606
+ df["_norm_surname"] = df.get("AU", pd.Series([""] * len(df))).apply(normalize_author_surname)
607
+ df["_norm_issn"] = df.get("SN", pd.Series([""] * len(df))).apply(normalize_issn)
608
+ df["_norm_pmid"] = df.get("PM", pd.Series([""] * len(df))).apply(normalize_id_token)
609
+ df["_norm_ut"] = df.get("UT", pd.Series([""] * len(df))).apply(normalize_id_token)
610
+ df["_norm_journal"] = df.get("SO", pd.Series([""] * len(df))).apply(normalize_title)
611
+ ctx.progress(0.20)
612
+
613
+ # 3. Blocking
614
+ wos_blocks = build_blocks(wos_df)
615
+ scp_blocks = build_blocks(scp_df)
616
+ common_keys = set(wos_blocks.keys()) & set(scp_blocks.keys())
617
+ ctx.log(f"Blocking: {len(wos_blocks)} WoS blocks × {len(scp_blocks)} Scopus blocks -> {len(common_keys)} common blocks")
618
+ ctx.progress(0.25)
619
+
620
+ # 4. Multi-stage matching (greedy assignment)
621
+ # Her WoS satırı en yüksek confidence Scopus satırıyla eşleşir (one-to-one)
622
+ matches: list[dict] = [] # match_audit rows
623
+ borderline: list[dict] = [] # borderline_queue rows
624
+ matched_wos: set[int] = set()
625
+ matched_scp: set[int] = set()
626
+
627
+ # Sayaçlar
628
+ stage_counts: dict[str, int] = {}
629
+
630
+ # Tüm aday çiftleri topla, confidence descending sırala
631
+ candidates: list[tuple[float, int, int, dict]] = []
632
+ pair_counter = 0
633
+ for key in common_keys:
634
+ for w_idx in wos_blocks[key]:
635
+ w_row = wos_df.loc[w_idx].to_dict()
636
+ for s_idx in scp_blocks[key]:
637
+ s_row = scp_df.loc[s_idx].to_dict()
638
+ m = compute_match(w_row, s_row)
639
+ if m is None:
640
+ continue
641
+ candidates.append((m["confidence"], w_idx, s_idx, m))
642
+
643
+ # Confidence descending
644
+ candidates.sort(key=lambda x: -x[0])
645
+ ctx.log(f"Candidate pairs: {len(candidates)}")
646
+ ctx.progress(0.40)
647
+
648
+ # Greedy assignment + borderline ayır
649
+ for conf, w_idx, s_idx, m in candidates:
650
+ if w_idx in matched_wos or s_idx in matched_scp:
651
+ continue
652
+
653
+ pair_counter += 1
654
+ pair_id = f"p{pair_counter:06d}"
655
+
656
+ if m["stage"] == "5_borderline":
657
+ # Borderline — UI'da manuel onay bekleyecek
658
+ w_row = wos_df.loc[w_idx]
659
+ s_row = scp_df.loc[s_idx]
660
+ borderline.append({
661
+ "pair_id": pair_id,
662
+ "wos_index": int(w_idx),
663
+ "scp_index": int(s_idx),
664
+ "jw_title": m["jw_title"],
665
+ "year_diff": m["year_diff"],
666
+ "surname_match": m["surname_match"],
667
+ "confidence": m["confidence"],
668
+ "stage_label": m["stage_label"],
669
+ "reason": m["reason"],
670
+ "wos_doi": _to_str(w_row.get("DI", "")),
671
+ "scp_doi": _to_str(s_row.get("DI", "")),
672
+ "wos_title": _to_str(w_row.get("TI", ""))[:200],
673
+ "scp_title": _to_str(s_row.get("TI", ""))[:200],
674
+ "wos_year": w_row.get("_norm_year"),
675
+ "scp_year": s_row.get("_norm_year"),
676
+ "wos_surname": _to_str(w_row.get("_norm_surname", "")),
677
+ "scp_surname": _to_str(s_row.get("_norm_surname", "")),
678
+ "wos_journal": _to_str(w_row.get("SO", "")),
679
+ "scp_journal": _to_str(s_row.get("SO", "")),
680
+ "wos_volume": _to_str(w_row.get("VL", "")),
681
+ "scp_volume": _to_str(s_row.get("VL", "")),
682
+ "status": "pending",
683
+ })
684
+ else:
685
+ # Definite match — birleştir
686
+ matched_wos.add(w_idx)
687
+ matched_scp.add(s_idx)
688
+ stage_counts[m["stage_label"]] = stage_counts.get(m["stage_label"], 0) + 1
689
+ matches.append({
690
+ "pair_id": pair_id,
691
+ "wos_index": int(w_idx),
692
+ "scp_index": int(s_idx),
693
+ "doi": m["reason"].split("DOI exact: ", 1)[-1].split(",")[0] if "DOI exact" in m["reason"] else "",
694
+ "stage": m["stage"],
695
+ "stage_label": m["stage_label"],
696
+ "confidence": m["confidence"],
697
+ "jw_title": m.get("jw_title"),
698
+ "year_diff": m.get("year_diff"),
699
+ "surname_match": m.get("surname_match"),
700
+ "reason": m["reason"],
701
+ })
702
+ ctx.log(f"Matches: {len(matches)} exact, {len(borderline)} borderline")
703
+ for stage_label, n in stage_counts.items():
704
+ ctx.log(f" Stage [{stage_label}]: {n}")
705
+ ctx.progress(0.65)
706
+
707
+ # 5. Field merge with preferences
708
+ ctx.log("Field merge (Caputo 2024 defaults)...")
709
+ all_columns = list(set(list(wos_df.columns) + list(scp_df.columns)))
710
+ conflicts: list[dict] = []
711
+ merged_rows: list[dict] = []
712
+ field_source_distribution: dict[str, int] = {}
713
+
714
+ for match in matches:
715
+ w_idx = match["wos_index"]
716
+ s_idx = match["scp_index"]
717
+ w_row = wos_df.loc[w_idx].to_dict()
718
+ s_row = scp_df.loc[s_idx].to_dict()
719
+ merged_row, pair_conflicts = merge_pair_with_preferences(
720
+ match["pair_id"], w_row, s_row, all_columns
721
+ )
722
+ merged_rows.append(merged_row)
723
+ conflicts.extend(pair_conflicts)
724
+ for c in pair_conflicts:
725
+ src = c["chosen_source"]
726
+ field_source_distribution[src] = field_source_distribution.get(src, 0) + 1
727
+
728
+ # 6. Eşleşmeyen WoS / Scopus satırları → ana df'ye olduğu gibi eklenir
729
+ wos_not_matched = wos_df.loc[~wos_df.index.isin(matched_wos)].copy()
730
+ scp_not_matched = scp_df.loc[~scp_df.index.isin(matched_scp)].copy()
731
+ # _norm_* kolonlarını çıkar
732
+ for df in (wos_not_matched, scp_not_matched):
733
+ drop_cols = [c for c in df.columns if c.startswith("_norm_")]
734
+ df.drop(columns=drop_cols, inplace=True, errors="ignore")
735
+
736
+ merged_df = pd.DataFrame(merged_rows)
737
+ # _norm_* drop
738
+ drop_cols = [c for c in merged_df.columns if c.startswith("_norm_")]
739
+ if drop_cols:
740
+ merged_df.drop(columns=drop_cols, inplace=True, errors="ignore")
741
+
742
+ # Tek bir DataFrame
743
+ final_df = pd.concat([merged_df, wos_not_matched, scp_not_matched], ignore_index=True)
744
+ ctx.progress(0.80)
745
+
746
+ # 7. UID kolonu ekle (filter_engine ile aynı şema)
747
+ filter_engine._ensure_uid_column(final_df)
748
+
749
+ # 8. Çıktıları yaz — analiz klasörüne
750
+ ctx.log("Writing output files...")
751
+ merged_xlsx = adir / "merged.xlsx"
752
+ audit_xlsx = adir / "match_audit.xlsx"
753
+ conflict_xlsx = adir / "conflict_log.xlsx"
754
+ borderline_xlsx = adir / "borderline_queue.xlsx"
755
+ lost_wos_xlsx = adir / "Lost_Wos_Records.xlsx"
756
+ lost_scp_xlsx = adir / "Lost_Scopus_Records.xlsx"
757
+ stat_xlsx = adir / "Statistic.xlsx"
758
+
759
+ try:
760
+ await asyncio.to_thread(final_df.to_excel, merged_xlsx, index=False)
761
+ await asyncio.to_thread(_write_match_audit, matches, audit_xlsx)
762
+ await asyncio.to_thread(_write_conflict_log, conflicts, conflict_xlsx)
763
+ await asyncio.to_thread(_write_borderline_queue, borderline, borderline_xlsx)
764
+ await asyncio.to_thread(_write_lost_records, wos_not_matched, lost_wos_xlsx)
765
+ await asyncio.to_thread(_write_lost_records, scp_not_matched, lost_scp_xlsx)
766
+ await asyncio.to_thread(
767
+ _write_statistic_smart,
768
+ len(final_df), len(wos_df), len(scp_df), final_df, stat_xlsx,
769
+ )
770
+ except Exception:
771
+ # Yazma sırasında hata — yarım analiz klasörünü temizle
772
+ try:
773
+ analyses.delete_analysis(project_id, analysis_id)
774
+ except Exception:
775
+ pass
776
+ raise
777
+
778
+ # 9. Borderline state'i kaydet (analiz klasörü içine)
779
+ state = {b["pair_id"]: {"status": "pending", "decided_at": None} for b in borderline}
780
+ if borderline:
781
+ _write_borderline_state(project_id, state, adir=adir)
782
+
783
+ # 10. Filter cache invalidate (Smart artık aktif olacak)
784
+ filter_engine._DF_CACHE.clear()
785
+
786
+ # 11. Finalize — file_count + aktif yap
787
+ analyses.finalize_analysis(project_id, analysis_id)
788
+
789
+ ctx.progress(1.0)
790
+ storage.touch_project(project_id)
791
+
792
+ # 12. Result özeti (audit hook için)
793
+ summary = {
794
+ "method": "smart",
795
+ "analysis_id": analysis_id,
796
+ "scopus_input": int(len(scp_df)),
797
+ "wos_input": int(len(wos_df)),
798
+ "merged_count": int(len(final_df)),
799
+ "matched_pairs": int(len(matches)),
800
+ "borderline_count": int(len(borderline)),
801
+ "borderline_pending": int(len(borderline)),
802
+ "conflict_count": int(len(conflicts)),
803
+ "match_stages": stage_counts,
804
+ "field_source_distribution": field_source_distribution,
805
+ "lost_wos_count": int(len(wos_not_matched)),
806
+ "lost_scopus_count": int(len(scp_not_matched)),
807
+ "output_xlsx": str(merged_xlsx.relative_to(storage.settings.storage_path)),
808
+ "output_files": [
809
+ f.name for f in (
810
+ merged_xlsx, audit_xlsx, conflict_xlsx, borderline_xlsx,
811
+ lost_wos_xlsx, lost_scp_xlsx, stat_xlsx,
812
+ ) if f.exists()
813
+ ],
814
+ }
815
+ ctx.log(f"Smart Merge complete — {summary['merged_count']} unique records "
816
+ f"({summary['matched_pairs']} matched + {summary['borderline_count']} borderline)")
817
+ return summary
818
+
819
+
820
+ # ════════════════════════════════════════════════════════════════════════
821
+ # FAZ 6 — Borderline review
822
+ # ════════════════════════════════════════════════════════════════════════
823
+
824
+ def list_borderline(project_id: str) -> list[dict]:
825
+ """borderline_queue.xlsx + borderline_state.json birleşik liste (aktif analizden)."""
826
+ adir = analyses.get_active_analysis_dir(project_id)
827
+ if adir is None:
828
+ return []
829
+ bq_path = adir / "borderline_queue.xlsx"
830
+ if not bq_path.exists():
831
+ return []
832
+ try:
833
+ df = pd.read_excel(bq_path)
834
+ except Exception:
835
+ return []
836
+ state = _read_borderline_state(project_id, adir=adir)
837
+ items: list[dict] = []
838
+ for _, row in df.iterrows():
839
+ pair_id = str(row.get("pair_id", ""))
840
+ st = state.get(pair_id, {"status": "pending"})
841
+ items.append({
842
+ "pair_id": pair_id,
843
+ "wos_index": int(row.get("wos_index", 0)),
844
+ "scp_index": int(row.get("scp_index", 0)),
845
+ "jw_title": float(row.get("jw_title") or 0),
846
+ "confidence": float(row.get("confidence") or 0),
847
+ "status": st.get("status", "pending"),
848
+ "decided_at": st.get("decided_at"),
849
+ "reason": str(row.get("reason", "")),
850
+ "wos": {
851
+ "doi": str(row.get("wos_doi", "")) or None,
852
+ "title": str(row.get("wos_title", "")),
853
+ "year": int(row["wos_year"]) if pd.notna(row.get("wos_year")) else None,
854
+ "surname": str(row.get("wos_surname", "")) or None,
855
+ "journal": str(row.get("wos_journal", "")) or None,
856
+ "volume": str(row.get("wos_volume", "")) or None,
857
+ },
858
+ "scopus": {
859
+ "doi": str(row.get("scp_doi", "")) or None,
860
+ "title": str(row.get("scp_title", "")),
861
+ "year": int(row["scp_year"]) if pd.notna(row.get("scp_year")) else None,
862
+ "surname": str(row.get("scp_surname", "")) or None,
863
+ "journal": str(row.get("scp_journal", "")) or None,
864
+ "volume": str(row.get("scp_volume", "")) or None,
865
+ },
866
+ "llm_suggestion": st.get("llm_suggestion"),
867
+ })
868
+ return items
869
+
870
+
871
+ def decide_borderline(project_id: str, decisions: list[dict]) -> dict[str, Any]:
872
+ """Kullanıcının borderline kararlarını uygula (aktif analiz üzerinde).
873
+
874
+ decisions: [{pair_id, decision: 'accept'|'reject'|'skip'}]
875
+ """
876
+ if not decisions:
877
+ return {"applied": 0, "snapshot": None, "pending_after": 0}
878
+
879
+ adir = analyses.get_active_analysis_dir(project_id)
880
+ if adir is None:
881
+ raise RuntimeError("Aktif analiz yok — önce Smart Merge çalıştırın")
882
+
883
+ merged_xlsx = adir / "merged.xlsx"
884
+ if not merged_xlsx.exists():
885
+ raise RuntimeError("merged.xlsx bulunamadı — önce Smart Merge çalıştırın")
886
+
887
+ bq_path = adir / "borderline_queue.xlsx"
888
+ if not bq_path.exists():
889
+ return {"applied": 0, "snapshot": None, "pending_after": 0}
890
+
891
+ # State güncelle
892
+ state = _read_borderline_state(project_id, adir=adir)
893
+ bq_df = pd.read_excel(bq_path)
894
+ bq_by_id = {str(r["pair_id"]): r for _, r in bq_df.iterrows()}
895
+
896
+ accept_pairs: list[dict] = []
897
+ now_ts = time.time()
898
+ for d in decisions:
899
+ pair_id = d.get("pair_id")
900
+ decision = d.get("decision")
901
+ if not pair_id or decision not in ("accept", "reject", "skip"):
902
+ continue
903
+ state[pair_id] = {
904
+ **state.get(pair_id, {}),
905
+ "status": decision,
906
+ "decided_at": now_ts,
907
+ }
908
+ if decision == "accept" and pair_id in bq_by_id:
909
+ accept_pairs.append(bq_by_id[pair_id])
910
+
911
+ _write_borderline_state(project_id, state, adir=adir)
912
+
913
+ # Kabul edilenler için dataset güncellemesi
914
+ snapshot_rel: Optional[str] = None
915
+ applied = 0
916
+ if accept_pairs:
917
+ df = pd.read_excel(merged_xlsx)
918
+ # Snapshot
919
+ snaps_dir = storage.project_dir(project_id) / "snapshots"
920
+ snaps_dir.mkdir(exist_ok=True)
921
+ stamp = time.strftime("%Y%m%d_%H%M%S")
922
+ snap_path = snaps_dir / f"pre_borderline_accept_{stamp}.xlsx"
923
+ df.to_excel(snap_path, index=False)
924
+ snapshot_rel = str(snap_path.relative_to(storage.settings.storage_path))
925
+
926
+ # Yeniden yükle WoS/Scopus için pair satırlarını birleştir
927
+ # Basit yaklaşım: accept edilen Scopus index'ini sil (WoS satırı zaten merged_df'te)
928
+ # Aslında merged_smart.xlsx'te ne WoS-only ne Scopus-only kayıtların ikisi de var
929
+ # (eşleşmemiş olarak). Şimdi accept = "bunlar aynı yayın" → Scopus satırını sil.
930
+ # Pair'lerden scp_doi'yi al, df'te o DOI'li satırı sil.
931
+ to_drop_dois: set[str] = set()
932
+ for pair in accept_pairs:
933
+ scp_doi = str(pair.get("scp_doi", "")).strip().lower()
934
+ if scp_doi:
935
+ to_drop_dois.add(scp_doi)
936
+ if to_drop_dois and "DI" in df.columns:
937
+ mask = df["DI"].astype(str).str.strip().str.lower().isin(to_drop_dois)
938
+ applied = int(mask.sum())
939
+ df = df.loc[~mask].reset_index(drop=True)
940
+ df.to_excel(merged_xlsx, index=False)
941
+ filter_engine._DF_CACHE.clear()
942
+
943
+ # Audit
944
+ _bl_accept = len([d for d in decisions if d.get("decision") == "accept"])
945
+ _bl_reject = len([d for d in decisions if d.get("decision") == "reject"])
946
+ _bl_skip = len([d for d in decisions if d.get("decision") == "skip"])
947
+ audit.write(
948
+ project_id,
949
+ kind="merge_borderline",
950
+ title=f"Borderline: {_bl_accept} kabul / {_bl_reject} red / {_bl_skip} atlandı",
951
+ title_key="audit.titles.borderlineDecisions",
952
+ title_params={"accept": _bl_accept, "reject": _bl_reject, "skip": _bl_skip},
953
+ details={
954
+ "decisions_count": len(decisions),
955
+ "applied_changes": applied,
956
+ "accept_pair_ids": [d.get("pair_id") for d in decisions if d.get("decision") == "accept"][:50],
957
+ },
958
+ snapshot=snapshot_rel,
959
+ user_action="borderline_decide",
960
+ )
961
+
962
+ pending_after = sum(1 for v in state.values() if v.get("status") == "pending")
963
+ return {
964
+ "applied": applied,
965
+ "snapshot": snapshot_rel,
966
+ "pending_after": pending_after,
967
+ }