BibexPy 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. bibex_core/MergeDB.py +1189 -0
  2. bibex_core/__init__.py +13 -0
  3. bibex_core/modules/__init__.py +35 -0
  4. bibex_core/modules/api_utils.py +1172 -0
  5. bibex_core/modules/c1_utils.py +423 -0
  6. bibex_core/modules/file_utils.py +71 -0
  7. bibex_core/modules/ml_utils.py +332 -0
  8. bibex_core/modules/post_process.py +50 -0
  9. bibex_core/modules/stats_utils.py +216 -0
  10. bibex_core/scp2xlsx.py +235 -0
  11. bibex_core/wos2xlsx.py +159 -0
  12. bibex_core/xlsx2vos.py +185 -0
  13. bibexpy/__init__.py +9 -0
  14. bibexpy/__main__.py +6 -0
  15. bibexpy/_server/__init__.py +1 -0
  16. bibexpy/_server/config.py +92 -0
  17. bibexpy/_server/jobs/__init__.py +3 -0
  18. bibexpy/_server/jobs/runner.py +301 -0
  19. bibexpy/_server/main.py +211 -0
  20. bibexpy/_server/models/__init__.py +0 -0
  21. bibexpy/_server/models/project.py +25 -0
  22. bibexpy/_server/routers/__init__.py +0 -0
  23. bibexpy/_server/routers/audit.py +65 -0
  24. bibexpy/_server/routers/convert.py +101 -0
  25. bibexpy/_server/routers/disambiguate.py +177 -0
  26. bibexpy/_server/routers/downloads.py +26 -0
  27. bibexpy/_server/routers/enrich.py +42 -0
  28. bibexpy/_server/routers/export.py +116 -0
  29. bibexpy/_server/routers/export_folder.py +97 -0
  30. bibexpy/_server/routers/filter.py +131 -0
  31. bibexpy/_server/routers/jobs.py +72 -0
  32. bibexpy/_server/routers/merge.py +437 -0
  33. bibexpy/_server/routers/prepare.py +241 -0
  34. bibexpy/_server/routers/projects.py +49 -0
  35. bibexpy/_server/routers/quality.py +288 -0
  36. bibexpy/_server/routers/records.py +204 -0
  37. bibexpy/_server/routers/report.py +111 -0
  38. bibexpy/_server/routers/settings.py +383 -0
  39. bibexpy/_server/routers/system.py +209 -0
  40. bibexpy/_server/routers/tools.py +257 -0
  41. bibexpy/_server/routers/upload.py +110 -0
  42. bibexpy/_server/services/__init__.py +0 -0
  43. bibexpy/_server/services/analyses.py +364 -0
  44. bibexpy/_server/services/audit.py +340 -0
  45. bibexpy/_server/services/bibex_adapter.py +47 -0
  46. bibexpy/_server/services/bibtex_writer.py +104 -0
  47. bibexpy/_server/services/converter.py +205 -0
  48. bibexpy/_server/services/disambiguation/__init__.py +19 -0
  49. bibexpy/_server/services/disambiguation/blocking.py +581 -0
  50. bibexpy/_server/services/disambiguation/cache.py +47 -0
  51. bibexpy/_server/services/disambiguation/deepseek_client.py +203 -0
  52. bibexpy/_server/services/disambiguation/orcid.py +261 -0
  53. bibexpy/_server/services/disambiguation/pipeline.py +985 -0
  54. bibexpy/_server/services/disambiguation/similarity.py +91 -0
  55. bibexpy/_server/services/enricher.py +338 -0
  56. bibexpy/_server/services/exporter.py +112 -0
  57. bibexpy/_server/services/filter_engine.py +300 -0
  58. bibexpy/_server/services/merger.py +202 -0
  59. bibexpy/_server/services/methodology.py +102 -0
  60. bibexpy/_server/services/report_export.py +300 -0
  61. bibexpy/_server/services/ris_writer.py +62 -0
  62. bibexpy/_server/services/smart_merger.py +967 -0
  63. bibexpy/_server/services/storage.py +165 -0
  64. bibexpy/_web/404/index.html +1 -0
  65. bibexpy/_web/404.html +1 -0
  66. bibexpy/_web/_next/static/chunks/117-7ad5186126fa076d.js +2 -0
  67. bibexpy/_web/_next/static/chunks/156-75beffb8ae92a7f3.js +1 -0
  68. bibexpy/_web/_next/static/chunks/169-301c04fcec22686f.js +4 -0
  69. bibexpy/_web/_next/static/chunks/2-ecc1c36a5632ac61.js +1 -0
  70. bibexpy/_web/_next/static/chunks/244-be343e0f8aa00fb5.js +1 -0
  71. bibexpy/_web/_next/static/chunks/367-b7457c2b95f6932b.js +1 -0
  72. bibexpy/_web/_next/static/chunks/382-9cd163eed632e241.js +1 -0
  73. bibexpy/_web/_next/static/chunks/392-afa8bfa4c1a9893d.js +1 -0
  74. bibexpy/_web/_next/static/chunks/507-63607ded9f83c1aa.js +1 -0
  75. bibexpy/_web/_next/static/chunks/624-cae565632f47217d.js +1 -0
  76. bibexpy/_web/_next/static/chunks/739-a00c6b9c0f11813e.js +1 -0
  77. bibexpy/_web/_next/static/chunks/862-8e1bd5f93c082c02.js +1 -0
  78. bibexpy/_web/_next/static/chunks/app/_not-found/page-873ba59b5eb7c342.js +1 -0
  79. bibexpy/_web/_next/static/chunks/app/layout-0d5fc68ac8971a04.js +1 -0
  80. bibexpy/_web/_next/static/chunks/app/page-cf2bea5edc74459a.js +1 -0
  81. bibexpy/_web/_next/static/chunks/app/projects/[id]/convert/page-ab6dd645ae113b64.js +1 -0
  82. bibexpy/_web/_next/static/chunks/app/projects/[id]/disambiguate/page-dd98482cc47344e9.js +1 -0
  83. bibexpy/_web/_next/static/chunks/app/projects/[id]/enrich/page-4727e431468514e9.js +1 -0
  84. bibexpy/_web/_next/static/chunks/app/projects/[id]/export/page-f9005d164bb22cb0.js +1 -0
  85. bibexpy/_web/_next/static/chunks/app/projects/[id]/layout-07755fda55b3ae1f.js +1 -0
  86. bibexpy/_web/_next/static/chunks/app/projects/[id]/merge/page-45b95318ea7beccf.js +1 -0
  87. bibexpy/_web/_next/static/chunks/app/projects/[id]/records/page-bfad80457da9eb0b.js +1 -0
  88. bibexpy/_web/_next/static/chunks/app/projects/[id]/report/page-f05767e74158e995.js +1 -0
  89. bibexpy/_web/_next/static/chunks/app/projects/[id]/upload/page-8df78ca9cdecbcb8.js +1 -0
  90. bibexpy/_web/_next/static/chunks/app/projects/page-99960711ac6a13e9.js +1 -0
  91. bibexpy/_web/_next/static/chunks/app/settings/page-002df809d6c5a227.js +1 -0
  92. bibexpy/_web/_next/static/chunks/app/tools/page-27eee1c6a590859f.js +1 -0
  93. bibexpy/_web/_next/static/chunks/fd9d1056-f2718be92181f426.js +1 -0
  94. bibexpy/_web/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
  95. bibexpy/_web/_next/static/chunks/main-app-d138a6e2f8c847f6.js +1 -0
  96. bibexpy/_web/_next/static/chunks/main-c4b9ddbf514503ef.js +1 -0
  97. bibexpy/_web/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
  98. bibexpy/_web/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
  99. bibexpy/_web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  100. bibexpy/_web/_next/static/chunks/webpack-c81f7fd28659d64f.js +1 -0
  101. bibexpy/_web/_next/static/css/1c4638f3e39cf127.css +3 -0
  102. bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_buildManifest.js +1 -0
  103. bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_ssgManifest.js +1 -0
  104. bibexpy/_web/images/PROMPTS.md +83 -0
  105. bibexpy/_web/images/about-illustration.png +0 -0
  106. bibexpy/_web/images/authors/alperen-sahin.jpg +0 -0
  107. bibexpy/_web/images/authors/burak-can-kara.jpg +0 -0
  108. bibexpy/_web/images/authors/taskin-dirsehan.jpg +0 -0
  109. bibexpy/_web/images/bibexpy-logo-full.png +0 -0
  110. bibexpy/_web/images/bibexpy-logo-header.png +0 -0
  111. bibexpy/_web/images/bibexpy-logo-in.png +0 -0
  112. bibexpy/_web/images/bibexpy-logo.webp +0 -0
  113. bibexpy/_web/images/hero-illustration.png +0 -0
  114. bibexpy/_web/images/loader.gif +0 -0
  115. bibexpy/_web/images/workflow-diagram.png +0 -0
  116. bibexpy/_web/index.html +1 -0
  117. bibexpy/_web/index.txt +11 -0
  118. bibexpy/_web/projects/_/convert/index.html +1 -0
  119. bibexpy/_web/projects/_/convert/index.txt +12 -0
  120. bibexpy/_web/projects/_/disambiguate/index.html +1 -0
  121. bibexpy/_web/projects/_/disambiguate/index.txt +12 -0
  122. bibexpy/_web/projects/_/enrich/index.html +1 -0
  123. bibexpy/_web/projects/_/enrich/index.txt +12 -0
  124. bibexpy/_web/projects/_/export/index.html +1 -0
  125. bibexpy/_web/projects/_/export/index.txt +12 -0
  126. bibexpy/_web/projects/_/merge/index.html +1 -0
  127. bibexpy/_web/projects/_/merge/index.txt +12 -0
  128. bibexpy/_web/projects/_/records/index.html +1 -0
  129. bibexpy/_web/projects/_/records/index.txt +12 -0
  130. bibexpy/_web/projects/_/report/index.html +1 -0
  131. bibexpy/_web/projects/_/report/index.txt +12 -0
  132. bibexpy/_web/projects/_/upload/index.html +1 -0
  133. bibexpy/_web/projects/_/upload/index.txt +12 -0
  134. bibexpy/_web/projects/index.html +1 -0
  135. bibexpy/_web/projects/index.txt +11 -0
  136. bibexpy/_web/settings/index.html +1 -0
  137. bibexpy/_web/settings/index.txt +11 -0
  138. bibexpy/_web/tools/README.md +65 -0
  139. bibexpy/_web/tools/bibexcel.svg +6 -0
  140. bibexpy/_web/tools/bibliometrix.png +0 -0
  141. bibexpy/_web/tools/biblioshiny.svg +12 -0
  142. bibexpy/_web/tools/citavi.svg +6 -0
  143. bibexpy/_web/tools/citespace.svg +7 -0
  144. bibexpy/_web/tools/citnetexplorer.svg +8 -0
  145. bibexpy/_web/tools/endnote.svg +6 -0
  146. bibexpy/_web/tools/excel.svg +1 -0
  147. bibexpy/_web/tools/gephi.svg +66 -0
  148. bibexpy/_web/tools/histcite.svg +7 -0
  149. bibexpy/_web/tools/index.html +1 -0
  150. bibexpy/_web/tools/index.txt +11 -0
  151. bibexpy/_web/tools/jabref.svg +122 -0
  152. bibexpy/_web/tools/latex.svg +1 -0
  153. bibexpy/_web/tools/mendeley.svg +1 -0
  154. bibexpy/_web/tools/openrefine.svg +15 -0
  155. bibexpy/_web/tools/overleaf.svg +1 -0
  156. bibexpy/_web/tools/papers.svg +7 -0
  157. bibexpy/_web/tools/powerbi.svg +1 -0
  158. bibexpy/_web/tools/python.svg +1 -0
  159. bibexpy/_web/tools/r.svg +1 -0
  160. bibexpy/_web/tools/refworks.svg +5 -0
  161. bibexpy/_web/tools/scite.svg +5 -0
  162. bibexpy/_web/tools/tableau.svg +1 -0
  163. bibexpy/_web/tools/vosviewer.png +0 -0
  164. bibexpy/_web/tools/zotero.svg +1 -0
  165. bibexpy/cli.py +242 -0
  166. bibexpy-2.0.0.dist-info/METADATA +105 -0
  167. bibexpy-2.0.0.dist-info/RECORD +171 -0
  168. bibexpy-2.0.0.dist-info/WHEEL +5 -0
  169. bibexpy-2.0.0.dist-info/entry_points.txt +2 -0
  170. bibexpy-2.0.0.dist-info/licenses/LICENSE +674 -0
  171. bibexpy-2.0.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,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}"