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