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,300 @@
|
|
|
1
|
+
"""Pandas tabanlı filtre motoru — UI'dan gelen filter spec'i datasete uygular.
|
|
2
|
+
|
|
3
|
+
Plan dosyasındaki şemayı izler:
|
|
4
|
+
year, doc_type, language, db_source, citation_count, journal, authors,
|
|
5
|
+
wc_categories, sc_categories, fulltext, quality
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Iterable, Optional
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from services import merger
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------- Dataset yükleme (cache) ----------
|
|
21
|
+
|
|
22
|
+
_DF_CACHE: dict[tuple[str, float], pd.DataFrame] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ensure_uid_column(df: pd.DataFrame) -> bool:
|
|
26
|
+
"""Her satıra benzersiz, stabil bir UID atar. UID kolonu yoksa ekler.
|
|
27
|
+
|
|
28
|
+
Return: True = ekledi (kaydetmek gerek), False = zaten vardı.
|
|
29
|
+
"""
|
|
30
|
+
import uuid
|
|
31
|
+
if "UID" in df.columns:
|
|
32
|
+
# Eksik / duplicate UID'leri doldur
|
|
33
|
+
s = df["UID"].astype(str).str.strip()
|
|
34
|
+
empty = (s == "") | (s.str.upper() == "NAN") | s.isna()
|
|
35
|
+
dups = s.duplicated(keep="first") & ~empty
|
|
36
|
+
if not empty.any() and not dups.any():
|
|
37
|
+
return False
|
|
38
|
+
# Eksik veya çakışan UID'lere yeni değer ata
|
|
39
|
+
existing = set(s[~empty & ~dups].tolist())
|
|
40
|
+
for i in df.index[empty | dups]:
|
|
41
|
+
while True:
|
|
42
|
+
nid = f"r_{uuid.uuid4().hex[:10]}"
|
|
43
|
+
if nid not in existing:
|
|
44
|
+
existing.add(nid)
|
|
45
|
+
df.at[i, "UID"] = nid
|
|
46
|
+
break
|
|
47
|
+
return True
|
|
48
|
+
# UID kolonu tamamen yok
|
|
49
|
+
df["UID"] = [f"r_{uuid.uuid4().hex[:10]}" for _ in range(len(df))]
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_merged(project_id: str) -> pd.DataFrame:
|
|
54
|
+
p = merger.merged_dataset_path(project_id)
|
|
55
|
+
if p is None:
|
|
56
|
+
raise FileNotFoundError("Birleştirilmiş veri yok — önce Merge çalıştırın")
|
|
57
|
+
mtime = p.stat().st_mtime
|
|
58
|
+
key = (str(p), mtime)
|
|
59
|
+
if key not in _DF_CACHE:
|
|
60
|
+
_DF_CACHE.clear() # tek-kullanıcı, tek-dataset
|
|
61
|
+
df = pd.read_excel(p)
|
|
62
|
+
# Tüm string sütunları normalize et — boş hücreler için empty string
|
|
63
|
+
for col in df.columns:
|
|
64
|
+
if df[col].dtype == "object":
|
|
65
|
+
df[col] = df[col].fillna("")
|
|
66
|
+
# Her satıra UID ata (yoksa) ve kalıcı olarak diske yaz
|
|
67
|
+
if _ensure_uid_column(df):
|
|
68
|
+
try:
|
|
69
|
+
df.to_excel(p, index=False)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass # write hatası ana akışı durdurmasın — UID memory'de yine var
|
|
72
|
+
_DF_CACHE[key] = df
|
|
73
|
+
return _DF_CACHE[key]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------- Filter spec uygulama ----------
|
|
77
|
+
|
|
78
|
+
def _has_col(df: pd.DataFrame, col: str) -> bool:
|
|
79
|
+
return col in df.columns
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _coerce_int_series(s: pd.Series) -> pd.Series:
|
|
83
|
+
return pd.to_numeric(s, errors="coerce")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _apply_range(df: pd.DataFrame, col: str, rng: dict[str, Any]) -> pd.Series:
|
|
87
|
+
"""Hem yıl hem citation gibi numerik aralık filtresi."""
|
|
88
|
+
if not _has_col(df, col):
|
|
89
|
+
return pd.Series(True, index=df.index)
|
|
90
|
+
nums = _coerce_int_series(df[col])
|
|
91
|
+
mask = pd.Series(True, index=df.index)
|
|
92
|
+
if rng.get("min") is not None:
|
|
93
|
+
mask &= nums >= float(rng["min"])
|
|
94
|
+
if rng.get("max") is not None:
|
|
95
|
+
mask &= nums <= float(rng["max"])
|
|
96
|
+
return mask
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _apply_in(df: pd.DataFrame, col: str, values: list[str]) -> pd.Series:
|
|
100
|
+
if not values or not _has_col(df, col):
|
|
101
|
+
return pd.Series(True, index=df.index)
|
|
102
|
+
norm = [str(v).strip().upper() for v in values if v]
|
|
103
|
+
series = df[col].astype(str).str.upper().str.strip()
|
|
104
|
+
return series.isin(norm)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _apply_contains_any(df: pd.DataFrame, col: str, values: list[str]) -> pd.Series:
|
|
108
|
+
"""Çoklu kategori alanları (WC, SC, AU) — değerler ';' veya ',' ile ayrılmış."""
|
|
109
|
+
if not values or not _has_col(df, col):
|
|
110
|
+
return pd.Series(True, index=df.index)
|
|
111
|
+
needles = [str(v).strip().upper() for v in values if v]
|
|
112
|
+
if not needles:
|
|
113
|
+
return pd.Series(True, index=df.index)
|
|
114
|
+
series = df[col].astype(str).str.upper()
|
|
115
|
+
pattern = "|".join(re.escape(n) for n in needles)
|
|
116
|
+
return series.str.contains(pattern, na=False, regex=True)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _fulltext_query(df: pd.DataFrame, query: str, fields: list[str]) -> pd.Series:
|
|
120
|
+
"""Basit AND/OR/NOT desteği. Tırnak içi ifadeler için literal eşleşme.
|
|
121
|
+
|
|
122
|
+
Örnek: '(machine OR deep) AND learning NOT survey'
|
|
123
|
+
"""
|
|
124
|
+
fields = [f for f in fields if _has_col(df, f)] or ["AB", "TI", "DE", "ID"]
|
|
125
|
+
fields = [f for f in fields if _has_col(df, f)]
|
|
126
|
+
if not fields:
|
|
127
|
+
return pd.Series(True, index=df.index)
|
|
128
|
+
|
|
129
|
+
combined = df[fields[0]].astype(str).str.upper()
|
|
130
|
+
for f in fields[1:]:
|
|
131
|
+
combined = combined + " || " + df[f].astype(str).str.upper()
|
|
132
|
+
|
|
133
|
+
q = query.strip().upper()
|
|
134
|
+
if not q:
|
|
135
|
+
return pd.Series(True, index=df.index)
|
|
136
|
+
|
|
137
|
+
# NOT terimleri çıkar
|
|
138
|
+
not_terms: list[str] = []
|
|
139
|
+
def _grab_not(m: re.Match) -> str:
|
|
140
|
+
not_terms.append(m.group(1).strip().strip('"'))
|
|
141
|
+
return " "
|
|
142
|
+
q = re.sub(r"\bNOT\s+(\"[^\"]+\"|\S+)", _grab_not, q)
|
|
143
|
+
|
|
144
|
+
# OR ayır (üst seviye)
|
|
145
|
+
or_clauses = [c.strip() for c in re.split(r"\bOR\b", q) if c.strip()]
|
|
146
|
+
if not or_clauses:
|
|
147
|
+
or_clauses = [q]
|
|
148
|
+
|
|
149
|
+
or_mask = pd.Series(False, index=df.index)
|
|
150
|
+
for clause in or_clauses:
|
|
151
|
+
# AND terimleri
|
|
152
|
+
and_terms = [t.strip().strip('"') for t in re.split(r"\bAND\b", clause) if t.strip()]
|
|
153
|
+
# Tek-terimli & parantez temizliği
|
|
154
|
+
and_terms = [t.strip("() ").strip() for t in and_terms if t.strip("() ").strip()]
|
|
155
|
+
if not and_terms:
|
|
156
|
+
continue
|
|
157
|
+
clause_mask = pd.Series(True, index=df.index)
|
|
158
|
+
for term in and_terms:
|
|
159
|
+
clause_mask &= combined.str.contains(re.escape(term), na=False, regex=True)
|
|
160
|
+
or_mask |= clause_mask
|
|
161
|
+
|
|
162
|
+
for nt in not_terms:
|
|
163
|
+
if nt:
|
|
164
|
+
or_mask &= ~combined.str.contains(re.escape(nt), na=False, regex=True)
|
|
165
|
+
|
|
166
|
+
return or_mask
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _apply_quality(df: pd.DataFrame, quality: dict[str, Any]) -> pd.Series:
|
|
170
|
+
mask = pd.Series(True, index=df.index)
|
|
171
|
+
for field in quality.get("missing", []) or []:
|
|
172
|
+
if not _has_col(df, field):
|
|
173
|
+
continue
|
|
174
|
+
s = df[field].astype(str).str.strip()
|
|
175
|
+
mask &= (s == "") | (s.str.upper() == "NAN")
|
|
176
|
+
for field in quality.get("has", []) or []:
|
|
177
|
+
if not _has_col(df, field):
|
|
178
|
+
continue
|
|
179
|
+
s = df[field].astype(str).str.strip()
|
|
180
|
+
mask &= (s != "") & (s.str.upper() != "NAN")
|
|
181
|
+
return mask
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def apply_filter(df: pd.DataFrame, spec: dict[str, Any]) -> pd.DataFrame:
|
|
185
|
+
if not spec:
|
|
186
|
+
return df
|
|
187
|
+
mask = pd.Series(True, index=df.index)
|
|
188
|
+
if "year" in spec and spec["year"]:
|
|
189
|
+
mask &= _apply_range(df, "PY", spec["year"])
|
|
190
|
+
if "citation_count" in spec and spec["citation_count"]:
|
|
191
|
+
mask &= _apply_range(df, "TC", spec["citation_count"])
|
|
192
|
+
if "doc_type" in spec:
|
|
193
|
+
mask &= _apply_in(df, "DT", spec["doc_type"])
|
|
194
|
+
if "language" in spec:
|
|
195
|
+
mask &= _apply_in(df, "LA", spec["language"])
|
|
196
|
+
if "db_source" in spec:
|
|
197
|
+
mask &= _apply_in(df, "DB", spec["db_source"])
|
|
198
|
+
if "journal" in spec:
|
|
199
|
+
mask &= _apply_contains_any(df, "SO", spec["journal"])
|
|
200
|
+
if "authors" in spec:
|
|
201
|
+
mask &= _apply_contains_any(df, "AU", spec["authors"])
|
|
202
|
+
if "wc_categories" in spec:
|
|
203
|
+
mask &= _apply_contains_any(df, "WC", spec["wc_categories"])
|
|
204
|
+
if "sc_categories" in spec:
|
|
205
|
+
mask &= _apply_contains_any(df, "SC", spec["sc_categories"])
|
|
206
|
+
if "fulltext" in spec and spec["fulltext"] and spec["fulltext"].get("query"):
|
|
207
|
+
fields = spec["fulltext"].get("fields") or ["AB", "TI", "DE", "ID"]
|
|
208
|
+
mask &= _fulltext_query(df, spec["fulltext"]["query"], fields)
|
|
209
|
+
if "quality" in spec and spec["quality"]:
|
|
210
|
+
mask &= _apply_quality(df, spec["quality"])
|
|
211
|
+
return df.loc[mask]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------- Facets ----------
|
|
215
|
+
|
|
216
|
+
DEFAULT_FACET_FIELDS = ["PY", "DT", "LA", "DB"]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _value_counts(series: pd.Series, top: int = 30) -> list[dict]:
|
|
220
|
+
s = series.astype(str).str.strip()
|
|
221
|
+
s = s[(s != "") & (s.str.upper() != "NAN")]
|
|
222
|
+
counts = s.value_counts().head(top)
|
|
223
|
+
return [{"value": str(k), "count": int(v)} for k, v in counts.items()]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def compute_facets(df: pd.DataFrame) -> dict[str, Any]:
|
|
227
|
+
facets: dict[str, Any] = {"total": int(len(df))}
|
|
228
|
+
if _has_col(df, "PY"):
|
|
229
|
+
years = pd.to_numeric(df["PY"], errors="coerce").dropna().astype(int)
|
|
230
|
+
if len(years):
|
|
231
|
+
histogram = years.value_counts().sort_index()
|
|
232
|
+
facets["year"] = {
|
|
233
|
+
"min": int(years.min()),
|
|
234
|
+
"max": int(years.max()),
|
|
235
|
+
"histogram": [{"year": int(y), "count": int(c)} for y, c in histogram.items()],
|
|
236
|
+
}
|
|
237
|
+
if _has_col(df, "TC"):
|
|
238
|
+
cit = pd.to_numeric(df["TC"], errors="coerce").dropna().astype(int)
|
|
239
|
+
if len(cit):
|
|
240
|
+
facets["citation_count"] = {"min": int(cit.min()), "max": int(cit.max()), "mean": float(cit.mean())}
|
|
241
|
+
for field, name in [("DT", "doc_type"), ("LA", "language"), ("DB", "db_source")]:
|
|
242
|
+
if _has_col(df, field):
|
|
243
|
+
facets[name] = _value_counts(df[field], top=20)
|
|
244
|
+
# Çoklu-değerli alanlar için top dergi/kategori (split-by-semicolon yapmadan)
|
|
245
|
+
if _has_col(df, "SO"):
|
|
246
|
+
facets["journal_top"] = _value_counts(df["SO"], top=20)
|
|
247
|
+
return facets
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---------- Sayfalama + projeksiyon ----------
|
|
251
|
+
|
|
252
|
+
# Bunlar UI'da varsayılan olarak görünür kolonlar (öncelik sırası); diğerleri açılır menüden açılır.
|
|
253
|
+
DISPLAY_COLS = ["TI", "AU", "SO", "PY", "TC", "DT", "LA", "DI", "DE", "DB"]
|
|
254
|
+
|
|
255
|
+
# UI tarafından bilinmeyen / faydasız kolonlar (Excel index, vb.)
|
|
256
|
+
SKIP_COLS = {"Unnamed: 0", "ER"}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def paginate(df: pd.DataFrame, offset: int = 0, limit: int = 50, columns: Optional[list[str]] = None) -> dict:
|
|
260
|
+
"""Tüm kolonları döndür (kullanıcı UI'dan filtreleyebilsin). columns verilirse onları kullan.
|
|
261
|
+
|
|
262
|
+
UID kolonu varsa her zaman dahil edilir (frontend row identification için).
|
|
263
|
+
"""
|
|
264
|
+
if columns:
|
|
265
|
+
cols = [c for c in columns if c in df.columns]
|
|
266
|
+
# UID her zaman dahil — UI'da görünmese bile seçim için lazım
|
|
267
|
+
if "UID" in df.columns and "UID" not in cols:
|
|
268
|
+
cols = ["UID"] + cols
|
|
269
|
+
else:
|
|
270
|
+
# Tüm kolonlar; ama UI'da en sık görünenler önce gelsin
|
|
271
|
+
all_cols = [c for c in df.columns if c not in SKIP_COLS]
|
|
272
|
+
priority = [c for c in DISPLAY_COLS if c in all_cols]
|
|
273
|
+
rest = [c for c in all_cols if c not in priority]
|
|
274
|
+
cols = priority + sorted(rest)
|
|
275
|
+
# UID en başa
|
|
276
|
+
if "UID" in df.columns and "UID" in cols:
|
|
277
|
+
cols = ["UID"] + [c for c in cols if c != "UID"]
|
|
278
|
+
|
|
279
|
+
total = int(len(df))
|
|
280
|
+
page = df.iloc[offset: offset + limit][cols]
|
|
281
|
+
records: list[dict[str, Any]] = []
|
|
282
|
+
for _, row in page.iterrows():
|
|
283
|
+
rec: dict[str, Any] = {}
|
|
284
|
+
for c in cols:
|
|
285
|
+
v = row[c]
|
|
286
|
+
if pd.isna(v):
|
|
287
|
+
rec[c] = None
|
|
288
|
+
elif isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
289
|
+
# Sayısal alanları string yap (NaN korunsun)
|
|
290
|
+
rec[c] = str(int(v)) if float(v).is_integer() else str(v)
|
|
291
|
+
else:
|
|
292
|
+
rec[c] = str(v) if v is not None else None
|
|
293
|
+
records.append(rec)
|
|
294
|
+
return {
|
|
295
|
+
"total": total,
|
|
296
|
+
"offset": offset,
|
|
297
|
+
"limit": limit,
|
|
298
|
+
"columns": cols,
|
|
299
|
+
"records": records,
|
|
300
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Birleştirme operasyonları sarmalı.
|
|
2
|
+
|
|
3
|
+
v2'de her birleştirme bağımsız bir analiz klasörü olarak saklanır
|
|
4
|
+
(`storage/<id>/analyses/analysis_<ts>_<method>/`). Bu modül kaynakları yükler ve
|
|
5
|
+
smart_merger pipeline'ına yönlendirir; pipeline kendi analiz klasörünü açıp
|
|
6
|
+
finalize eder.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from fastapi import HTTPException
|
|
16
|
+
|
|
17
|
+
from jobs.runner import JobContext
|
|
18
|
+
from services import analyses, converter, storage
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _project_paths(project_id: str) -> tuple[Path, Path, Path]:
|
|
22
|
+
"""(root, raw, processed) — proje veri klasörleri."""
|
|
23
|
+
meta = storage.get_project(project_id)
|
|
24
|
+
if meta is None:
|
|
25
|
+
raise HTTPException(404, "Proje bulunamadı")
|
|
26
|
+
root = storage.project_dir(project_id)
|
|
27
|
+
return root, root / "raw", root / "processed"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _find_xlsx(processed: Path, raw: Path, hint: str) -> Path | None:
|
|
31
|
+
"""Önce processed, sonra raw'da ara."""
|
|
32
|
+
for d in (processed, raw):
|
|
33
|
+
if not d.exists():
|
|
34
|
+
continue
|
|
35
|
+
for f in d.iterdir():
|
|
36
|
+
if not f.is_file():
|
|
37
|
+
continue
|
|
38
|
+
if f.suffix.lower() == ".xlsx" and hint in f.name.lower():
|
|
39
|
+
return f
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def check_ready_to_merge(project_id: str) -> None:
|
|
44
|
+
"""Merge başlamadan ÖNCE çağrılır (job submit etmeden). Hazırlık artık merge
|
|
45
|
+
job'unun ilk fazında ÖRTÜK yapıldığından ham veri yeterlidir; yalnızca hiç
|
|
46
|
+
kaynak veri yoksa 409 'no_source_data' fırlatır (UI kullanıcıyı yüklemeye yönlendirir).
|
|
47
|
+
"""
|
|
48
|
+
_, raw, processed = _project_paths(project_id)
|
|
49
|
+
has_xlsx = bool(
|
|
50
|
+
_find_xlsx(processed, raw, "scopus") or _find_xlsx(processed, raw, "scp")
|
|
51
|
+
or _find_xlsx(processed, raw, "wos")
|
|
52
|
+
)
|
|
53
|
+
has_raw = raw.exists() and any(
|
|
54
|
+
f.is_file() and f.suffix.lower() in (".csv", ".txt", ".isi", ".xlsx")
|
|
55
|
+
for f in raw.iterdir()
|
|
56
|
+
)
|
|
57
|
+
if has_xlsx or has_raw:
|
|
58
|
+
return
|
|
59
|
+
raise HTTPException(409, "no_source_data")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_inputs(
|
|
63
|
+
project_id: str, ctx: JobContext | None = None
|
|
64
|
+
) -> tuple[pd.DataFrame | None, pd.DataFrame | None]:
|
|
65
|
+
"""Scopus + WoS XLSX'leri yükle. Eksik biri varsa CSV/TXT'den otomatik dönüştür.
|
|
66
|
+
|
|
67
|
+
Tek kaynak varsa onu döndürür, diğeri None olur (birleştirme adımı bunu
|
|
68
|
+
passthrough yapar). YALNIZCA ikisi de yoksa hata verir — kullanıcı sadece
|
|
69
|
+
Scopus ya da sadece WoS verisini hazırlamak isteyebilir.
|
|
70
|
+
"""
|
|
71
|
+
_, raw, processed = _project_paths(project_id)
|
|
72
|
+
|
|
73
|
+
scp_xlsx = _find_xlsx(processed, raw, "scopus") or _find_xlsx(processed, raw, "scp")
|
|
74
|
+
wos_xlsx = _find_xlsx(processed, raw, "wos")
|
|
75
|
+
|
|
76
|
+
# Hazırlanmış (processed) XLSX yoksa merge ham CSV/TXT'den OTOMATİK üretmez —
|
|
77
|
+
# bu 1. adımı (Preparing) baypas ederdi. Bunun yerine, ham veri var ama hazırlık
|
|
78
|
+
# yapılmamışsa kullanıcıyı 1. adıma yönlendiren net bir hata döndürürüz.
|
|
79
|
+
if scp_xlsx is None and wos_xlsx is None:
|
|
80
|
+
has_raw = raw.exists() and any(
|
|
81
|
+
f.suffix.lower() in (".csv", ".txt", ".isi", ".xlsx")
|
|
82
|
+
for f in raw.iterdir() if f.is_file()
|
|
83
|
+
)
|
|
84
|
+
# Ham veri VAR ama hazırlanmamış → "önce hazırla"; hiç veri yoksa → "önce yükle".
|
|
85
|
+
raise HTTPException(409, "not_prepared" if has_raw else "no_source_data")
|
|
86
|
+
|
|
87
|
+
scp_df = pd.read_excel(scp_xlsx) if scp_xlsx is not None else None
|
|
88
|
+
wos_df = pd.read_excel(wos_xlsx) if wos_xlsx is not None else None
|
|
89
|
+
if ctx:
|
|
90
|
+
ctx.log(
|
|
91
|
+
f"Sources — Scopus: {len(scp_df) if scp_df is not None else 'none'}, "
|
|
92
|
+
f"WoS: {len(wos_df) if wos_df is not None else 'none'}"
|
|
93
|
+
)
|
|
94
|
+
return scp_df, wos_df
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _run_single_source(
|
|
98
|
+
ctx: JobContext, project_id: str,
|
|
99
|
+
scp_df: pd.DataFrame | None, wos_df: pd.DataFrame | None,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
"""Tek kaynak (yalnız Scopus veya yalnız WoS) — birleştirilecek ikinci veri yok.
|
|
102
|
+
Tek kaynağı doğrudan analiz veri seti yapar (dedup yok), filtreye hazır.
|
|
103
|
+
"""
|
|
104
|
+
import asyncio
|
|
105
|
+
df = scp_df if scp_df is not None else wos_df
|
|
106
|
+
src = "Scopus" if scp_df is not None else "WoS"
|
|
107
|
+
ctx.log(f"Single source ({src}) — no second dataset to merge, used directly (no dedup).")
|
|
108
|
+
ctx.progress(0.3)
|
|
109
|
+
|
|
110
|
+
analysis_id, adir = analyses.create_analysis(project_id, "single")
|
|
111
|
+
ctx.log(f"Analysis folder: {analysis_id}")
|
|
112
|
+
output_xlsx = adir / "merged.xlsx"
|
|
113
|
+
await asyncio.to_thread(lambda: df.to_excel(output_xlsx, index=False))
|
|
114
|
+
ctx.progress(0.7)
|
|
115
|
+
|
|
116
|
+
scp_count = int(len(scp_df)) if scp_df is not None else 0
|
|
117
|
+
wos_count = int(len(wos_df)) if wos_df is not None else 0
|
|
118
|
+
statistic_xlsx = adir / "Statistic.xlsx"
|
|
119
|
+
try:
|
|
120
|
+
from services.smart_merger import _write_statistic_smart
|
|
121
|
+
await asyncio.to_thread(
|
|
122
|
+
_write_statistic_smart, int(len(df)), wos_count, scp_count, df, statistic_xlsx,
|
|
123
|
+
)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
ctx.log(f"Statistic.xlsx could not be generated: {e}")
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
from services import filter_engine
|
|
129
|
+
filter_engine._DF_CACHE.clear()
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
analyses.finalize_analysis(project_id, analysis_id)
|
|
134
|
+
storage.touch_project(project_id)
|
|
135
|
+
ctx.progress(1.0)
|
|
136
|
+
ctx.log(f"Single source ready — {len(df)} records. You can proceed to the filtering step.")
|
|
137
|
+
return {
|
|
138
|
+
"method": "single",
|
|
139
|
+
"analysis_id": analysis_id,
|
|
140
|
+
"scopus_input": scp_count,
|
|
141
|
+
"wos_input": wos_count,
|
|
142
|
+
"merged_count": int(len(df)),
|
|
143
|
+
"duplicates_removed": 0,
|
|
144
|
+
"output_xlsx": str(output_xlsx.relative_to(storage.settings.storage_path)),
|
|
145
|
+
"stats": {},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def run_merge(ctx: JobContext, project_id: str) -> dict[str, Any]:
|
|
150
|
+
"""Birleştirme jobu — tek algoritma: Smart Merge.
|
|
151
|
+
|
|
152
|
+
Tek kaynak (yalnız Scopus veya yalnız WoS) varsa passthrough (dedup yok);
|
|
153
|
+
iki kaynak varsa bağımsız smart_merger pipeline'ı çalışır — kendi analiz
|
|
154
|
+
klasörünü açar, finalize eder ve özetini döndürür.
|
|
155
|
+
"""
|
|
156
|
+
import asyncio
|
|
157
|
+
|
|
158
|
+
ctx.log("Starting merge (Smart Merge)")
|
|
159
|
+
ctx.progress(0.05)
|
|
160
|
+
|
|
161
|
+
# Örtük hazırlık — ham CSV/TXT'leri konsolide XLSX'e çevir (ayrı Prepare adımı yok).
|
|
162
|
+
# Skip-if-fresh: yalnız ham dosya processed'tan yeniyse yeniden çevirir.
|
|
163
|
+
ctx.log("Preparing sources (CSV/TXT → XLSX)")
|
|
164
|
+
await asyncio.to_thread(converter.auto_prepare, project_id, ctx)
|
|
165
|
+
|
|
166
|
+
# Kaynakları yükle — tek-kaynak tespiti dispatch'ten önce yapılmalı.
|
|
167
|
+
scp_df, wos_df = await asyncio.to_thread(_load_inputs, project_id, ctx)
|
|
168
|
+
|
|
169
|
+
# Tek kaynak: birleştirilecek ikinci veri yok → passthrough (dedup yok),
|
|
170
|
+
# doğrudan filtreye hazır analiz veri seti.
|
|
171
|
+
if scp_df is None or wos_df is None:
|
|
172
|
+
return await _run_single_source(ctx, project_id, scp_df, wos_df)
|
|
173
|
+
|
|
174
|
+
# İki kaynak da var → Smart Merge (bağımsız pipeline, kendi klasörünü açar).
|
|
175
|
+
from services import smart_merger
|
|
176
|
+
return await smart_merger.run_smart_merge(ctx, project_id)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def list_merged(project_id: str) -> list[dict]:
|
|
180
|
+
"""Aktif analiz klasöründeki dosyaları listele."""
|
|
181
|
+
adir = analyses.get_active_analysis_dir(project_id)
|
|
182
|
+
if adir is None:
|
|
183
|
+
return []
|
|
184
|
+
out = []
|
|
185
|
+
for f in sorted(adir.iterdir()):
|
|
186
|
+
if not f.is_file() or f.name == "meta.json":
|
|
187
|
+
continue
|
|
188
|
+
out.append({
|
|
189
|
+
"name": f.name,
|
|
190
|
+
"size": f.stat().st_size,
|
|
191
|
+
"relative_path": str(f.relative_to(storage.settings.storage_path)),
|
|
192
|
+
})
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def merged_dataset_path(project_id: str) -> Path | None:
|
|
197
|
+
"""Filter/export için ana birleştirilmiş XLSX'i bul.
|
|
198
|
+
|
|
199
|
+
Aktif analiz klasöründen okur. Records / Filter / Quality / Disambiguation
|
|
200
|
+
otomatik olarak aktif analizin dataset'ini görür.
|
|
201
|
+
"""
|
|
202
|
+
return analyses.active_dataset_path(project_id)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""LLM ile metodoloji raporu üretimi.
|
|
2
|
+
|
|
3
|
+
Operasyon günlüğü (audit log) bir LLM'e gönderilir; LLM, bibliometrik bir
|
|
4
|
+
makalenin "Veri Hazırlama / Metodoloji" alt bölümüne yazılabilecek İngilizce
|
|
5
|
+
bir anlatı döndürür. Sonuç projede saklanır ve MD/TXT/PDF olarak indirilebilir.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from fastapi import HTTPException
|
|
16
|
+
|
|
17
|
+
from config import settings
|
|
18
|
+
from services import report_export, storage
|
|
19
|
+
from services.disambiguation.deepseek_client import (
|
|
20
|
+
DeepSeekClient, DeepSeekError, METHODOLOGY_SYSTEM_PROMPT,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Üretilen metinde her zaman bulunması gereken BibexPy künyesi (APA7, kullanıcı zorunlu kıldı).
|
|
25
|
+
BIBEXPY_CITATION = (
|
|
26
|
+
"Kara, B. C., Şahin, A., & Dirsehan, T. (2025). BibexPy: Harmonizing the "
|
|
27
|
+
"bibliometric symphony of Scopus and Web of Science. SoftwareX, 30, 102098. "
|
|
28
|
+
"https://doi.org/10.1016/j.softx.2025.102098"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _ensure_citation(text: str) -> str:
|
|
33
|
+
"""LLM atfı eklemediyse deterministik olarak ekle (çift eklemeyi önle)."""
|
|
34
|
+
low = text.lower()
|
|
35
|
+
if "softx" in low or "dirsehan" in low or "102098" in low:
|
|
36
|
+
return text
|
|
37
|
+
return text.rstrip() + "\n\n## Reference\n\n" + BIBEXPY_CITATION
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _report_path(project_id: str) -> Path:
|
|
41
|
+
return storage.project_dir(project_id) / "methodology_report.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _client() -> DeepSeekClient:
|
|
45
|
+
api_key = settings.effective_llm_api_key
|
|
46
|
+
if not api_key:
|
|
47
|
+
raise HTTPException(400, "llm_key_missing")
|
|
48
|
+
return DeepSeekClient(
|
|
49
|
+
api_key=api_key,
|
|
50
|
+
base_url=settings.effective_llm_base_url,
|
|
51
|
+
model=settings.effective_llm_model,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_methodology(project_id: str) -> Optional[dict[str, Any]]:
|
|
56
|
+
"""Saklanan metodoloji raporunu döndür (yoksa None)."""
|
|
57
|
+
p = _report_path(project_id)
|
|
58
|
+
if not p.exists():
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def generate_methodology(project_id: str) -> dict[str, Any]:
|
|
67
|
+
"""Günlükten LLM ile İngilizce metodoloji raporu üret, sakla ve döndür."""
|
|
68
|
+
digest = report_export.build_log_digest(project_id)
|
|
69
|
+
if not digest.get("steps"):
|
|
70
|
+
raise HTTPException(400, "no_operations")
|
|
71
|
+
|
|
72
|
+
client = _client() # anahtar yoksa burada llm_key_missing fırlatır
|
|
73
|
+
try:
|
|
74
|
+
text = client.chat_text(
|
|
75
|
+
METHODOLOGY_SYSTEM_PROMPT,
|
|
76
|
+
json.dumps(digest, ensure_ascii=False),
|
|
77
|
+
temperature=0.3,
|
|
78
|
+
)
|
|
79
|
+
except DeepSeekError as e:
|
|
80
|
+
raise HTTPException(502, f"llm_request_failed: {e}")
|
|
81
|
+
|
|
82
|
+
report = {
|
|
83
|
+
"text": _ensure_citation((text or "").strip()),
|
|
84
|
+
"generated_at": time.time(),
|
|
85
|
+
"model": settings.effective_llm_model,
|
|
86
|
+
"provider": settings.llm_provider or "deepseek",
|
|
87
|
+
"lang": "en",
|
|
88
|
+
"step_count": len(digest["steps"]),
|
|
89
|
+
}
|
|
90
|
+
try:
|
|
91
|
+
_report_path(project_id).write_text(
|
|
92
|
+
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
93
|
+
)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
return report
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def methodology_title(project_id: str) -> str:
|
|
100
|
+
meta = storage.get_project(project_id)
|
|
101
|
+
name = meta.name if meta else project_id
|
|
102
|
+
return f"Data Preparation Methodology — {name}"
|