BibexPy 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bibex_core/MergeDB.py +1189 -0
- bibex_core/__init__.py +13 -0
- bibex_core/modules/__init__.py +35 -0
- bibex_core/modules/api_utils.py +1172 -0
- bibex_core/modules/c1_utils.py +423 -0
- bibex_core/modules/file_utils.py +71 -0
- bibex_core/modules/ml_utils.py +332 -0
- bibex_core/modules/post_process.py +50 -0
- bibex_core/modules/stats_utils.py +216 -0
- bibex_core/scp2xlsx.py +235 -0
- bibex_core/wos2xlsx.py +159 -0
- bibex_core/xlsx2vos.py +185 -0
- bibexpy/__init__.py +9 -0
- bibexpy/__main__.py +6 -0
- bibexpy/_server/__init__.py +1 -0
- bibexpy/_server/config.py +92 -0
- bibexpy/_server/jobs/__init__.py +3 -0
- bibexpy/_server/jobs/runner.py +301 -0
- bibexpy/_server/main.py +211 -0
- bibexpy/_server/models/__init__.py +0 -0
- bibexpy/_server/models/project.py +25 -0
- bibexpy/_server/routers/__init__.py +0 -0
- bibexpy/_server/routers/audit.py +65 -0
- bibexpy/_server/routers/convert.py +101 -0
- bibexpy/_server/routers/disambiguate.py +177 -0
- bibexpy/_server/routers/downloads.py +26 -0
- bibexpy/_server/routers/enrich.py +42 -0
- bibexpy/_server/routers/export.py +116 -0
- bibexpy/_server/routers/export_folder.py +97 -0
- bibexpy/_server/routers/filter.py +131 -0
- bibexpy/_server/routers/jobs.py +72 -0
- bibexpy/_server/routers/merge.py +437 -0
- bibexpy/_server/routers/prepare.py +241 -0
- bibexpy/_server/routers/projects.py +49 -0
- bibexpy/_server/routers/quality.py +288 -0
- bibexpy/_server/routers/records.py +204 -0
- bibexpy/_server/routers/report.py +111 -0
- bibexpy/_server/routers/settings.py +383 -0
- bibexpy/_server/routers/system.py +209 -0
- bibexpy/_server/routers/tools.py +257 -0
- bibexpy/_server/routers/upload.py +110 -0
- bibexpy/_server/services/__init__.py +0 -0
- bibexpy/_server/services/analyses.py +364 -0
- bibexpy/_server/services/audit.py +340 -0
- bibexpy/_server/services/bibex_adapter.py +47 -0
- bibexpy/_server/services/bibtex_writer.py +104 -0
- bibexpy/_server/services/converter.py +205 -0
- bibexpy/_server/services/disambiguation/__init__.py +19 -0
- bibexpy/_server/services/disambiguation/blocking.py +581 -0
- bibexpy/_server/services/disambiguation/cache.py +47 -0
- bibexpy/_server/services/disambiguation/deepseek_client.py +203 -0
- bibexpy/_server/services/disambiguation/orcid.py +261 -0
- bibexpy/_server/services/disambiguation/pipeline.py +985 -0
- bibexpy/_server/services/disambiguation/similarity.py +91 -0
- bibexpy/_server/services/enricher.py +338 -0
- bibexpy/_server/services/exporter.py +112 -0
- bibexpy/_server/services/filter_engine.py +300 -0
- bibexpy/_server/services/merger.py +202 -0
- bibexpy/_server/services/methodology.py +102 -0
- bibexpy/_server/services/report_export.py +300 -0
- bibexpy/_server/services/ris_writer.py +62 -0
- bibexpy/_server/services/smart_merger.py +967 -0
- bibexpy/_server/services/storage.py +165 -0
- bibexpy/_web/404/index.html +1 -0
- bibexpy/_web/404.html +1 -0
- bibexpy/_web/_next/static/chunks/117-7ad5186126fa076d.js +2 -0
- bibexpy/_web/_next/static/chunks/156-75beffb8ae92a7f3.js +1 -0
- bibexpy/_web/_next/static/chunks/169-301c04fcec22686f.js +4 -0
- bibexpy/_web/_next/static/chunks/2-ecc1c36a5632ac61.js +1 -0
- bibexpy/_web/_next/static/chunks/244-be343e0f8aa00fb5.js +1 -0
- bibexpy/_web/_next/static/chunks/367-b7457c2b95f6932b.js +1 -0
- bibexpy/_web/_next/static/chunks/382-9cd163eed632e241.js +1 -0
- bibexpy/_web/_next/static/chunks/392-afa8bfa4c1a9893d.js +1 -0
- bibexpy/_web/_next/static/chunks/507-63607ded9f83c1aa.js +1 -0
- bibexpy/_web/_next/static/chunks/624-cae565632f47217d.js +1 -0
- bibexpy/_web/_next/static/chunks/739-a00c6b9c0f11813e.js +1 -0
- bibexpy/_web/_next/static/chunks/862-8e1bd5f93c082c02.js +1 -0
- bibexpy/_web/_next/static/chunks/app/_not-found/page-873ba59b5eb7c342.js +1 -0
- bibexpy/_web/_next/static/chunks/app/layout-0d5fc68ac8971a04.js +1 -0
- bibexpy/_web/_next/static/chunks/app/page-cf2bea5edc74459a.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/convert/page-ab6dd645ae113b64.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/disambiguate/page-dd98482cc47344e9.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/enrich/page-4727e431468514e9.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/export/page-f9005d164bb22cb0.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/layout-07755fda55b3ae1f.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/merge/page-45b95318ea7beccf.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/records/page-bfad80457da9eb0b.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/report/page-f05767e74158e995.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/upload/page-8df78ca9cdecbcb8.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/page-99960711ac6a13e9.js +1 -0
- bibexpy/_web/_next/static/chunks/app/settings/page-002df809d6c5a227.js +1 -0
- bibexpy/_web/_next/static/chunks/app/tools/page-27eee1c6a590859f.js +1 -0
- bibexpy/_web/_next/static/chunks/fd9d1056-f2718be92181f426.js +1 -0
- bibexpy/_web/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
- bibexpy/_web/_next/static/chunks/main-app-d138a6e2f8c847f6.js +1 -0
- bibexpy/_web/_next/static/chunks/main-c4b9ddbf514503ef.js +1 -0
- bibexpy/_web/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
- bibexpy/_web/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
- bibexpy/_web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- bibexpy/_web/_next/static/chunks/webpack-c81f7fd28659d64f.js +1 -0
- bibexpy/_web/_next/static/css/1c4638f3e39cf127.css +3 -0
- bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_buildManifest.js +1 -0
- bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_ssgManifest.js +1 -0
- bibexpy/_web/images/PROMPTS.md +83 -0
- bibexpy/_web/images/about-illustration.png +0 -0
- bibexpy/_web/images/authors/alperen-sahin.jpg +0 -0
- bibexpy/_web/images/authors/burak-can-kara.jpg +0 -0
- bibexpy/_web/images/authors/taskin-dirsehan.jpg +0 -0
- bibexpy/_web/images/bibexpy-logo-full.png +0 -0
- bibexpy/_web/images/bibexpy-logo-header.png +0 -0
- bibexpy/_web/images/bibexpy-logo-in.png +0 -0
- bibexpy/_web/images/bibexpy-logo.webp +0 -0
- bibexpy/_web/images/hero-illustration.png +0 -0
- bibexpy/_web/images/loader.gif +0 -0
- bibexpy/_web/images/workflow-diagram.png +0 -0
- bibexpy/_web/index.html +1 -0
- bibexpy/_web/index.txt +11 -0
- bibexpy/_web/projects/_/convert/index.html +1 -0
- bibexpy/_web/projects/_/convert/index.txt +12 -0
- bibexpy/_web/projects/_/disambiguate/index.html +1 -0
- bibexpy/_web/projects/_/disambiguate/index.txt +12 -0
- bibexpy/_web/projects/_/enrich/index.html +1 -0
- bibexpy/_web/projects/_/enrich/index.txt +12 -0
- bibexpy/_web/projects/_/export/index.html +1 -0
- bibexpy/_web/projects/_/export/index.txt +12 -0
- bibexpy/_web/projects/_/merge/index.html +1 -0
- bibexpy/_web/projects/_/merge/index.txt +12 -0
- bibexpy/_web/projects/_/records/index.html +1 -0
- bibexpy/_web/projects/_/records/index.txt +12 -0
- bibexpy/_web/projects/_/report/index.html +1 -0
- bibexpy/_web/projects/_/report/index.txt +12 -0
- bibexpy/_web/projects/_/upload/index.html +1 -0
- bibexpy/_web/projects/_/upload/index.txt +12 -0
- bibexpy/_web/projects/index.html +1 -0
- bibexpy/_web/projects/index.txt +11 -0
- bibexpy/_web/settings/index.html +1 -0
- bibexpy/_web/settings/index.txt +11 -0
- bibexpy/_web/tools/README.md +65 -0
- bibexpy/_web/tools/bibexcel.svg +6 -0
- bibexpy/_web/tools/bibliometrix.png +0 -0
- bibexpy/_web/tools/biblioshiny.svg +12 -0
- bibexpy/_web/tools/citavi.svg +6 -0
- bibexpy/_web/tools/citespace.svg +7 -0
- bibexpy/_web/tools/citnetexplorer.svg +8 -0
- bibexpy/_web/tools/endnote.svg +6 -0
- bibexpy/_web/tools/excel.svg +1 -0
- bibexpy/_web/tools/gephi.svg +66 -0
- bibexpy/_web/tools/histcite.svg +7 -0
- bibexpy/_web/tools/index.html +1 -0
- bibexpy/_web/tools/index.txt +11 -0
- bibexpy/_web/tools/jabref.svg +122 -0
- bibexpy/_web/tools/latex.svg +1 -0
- bibexpy/_web/tools/mendeley.svg +1 -0
- bibexpy/_web/tools/openrefine.svg +15 -0
- bibexpy/_web/tools/overleaf.svg +1 -0
- bibexpy/_web/tools/papers.svg +7 -0
- bibexpy/_web/tools/powerbi.svg +1 -0
- bibexpy/_web/tools/python.svg +1 -0
- bibexpy/_web/tools/r.svg +1 -0
- bibexpy/_web/tools/refworks.svg +5 -0
- bibexpy/_web/tools/scite.svg +5 -0
- bibexpy/_web/tools/tableau.svg +1 -0
- bibexpy/_web/tools/vosviewer.png +0 -0
- bibexpy/_web/tools/zotero.svg +1 -0
- bibexpy/cli.py +242 -0
- bibexpy-2.0.0.dist-info/METADATA +105 -0
- bibexpy-2.0.0.dist-info/RECORD +171 -0
- bibexpy-2.0.0.dist-info/WHEEL +5 -0
- bibexpy-2.0.0.dist-info/entry_points.txt +2 -0
- bibexpy-2.0.0.dist-info/licenses/LICENSE +674 -0
- bibexpy-2.0.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,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
|
+
}
|