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,364 @@
1
+ """Birleştirme analizleri (analysis runs) yönetimi.
2
+
3
+ Her birleştirme bağımsız bir analiz klasörü olarak saklanır. Bu sayede:
4
+ - Eski birleştirmeler kaybolmaz
5
+ - Farklı algoritmaların sonuçları yan yana saklanır
6
+ - Aktif analiz değiştirilebilir (Records/Filter/Quality otomatik yansır)
7
+ - Snapshot/rollback işlemleri daha güvenli
8
+
9
+ Disk yapısı:
10
+ storage/<project_id>/
11
+ analyses/
12
+ active.json # {"id": "analysis_20260526_071505_smart"}
13
+ analysis_20260526_071505_smart/
14
+ meta.json # {id, created_at, method, label, file_count}
15
+ merged.xlsx # ana dataset
16
+ Statistic.xlsx
17
+ match_audit.xlsx # smart only
18
+ conflict_log.xlsx # smart only
19
+ borderline_queue.xlsx # smart only
20
+ borderline_state.json # smart only
21
+ Lost_Wos.xlsx
22
+ Lost_Scopus.xlsx
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import shutil
29
+ import time
30
+ from datetime import datetime
31
+ from pathlib import Path
32
+ from typing import Any, Optional
33
+
34
+ from services import storage
35
+
36
+
37
+ # ─────────────────────────────────────────────────────────────
38
+ # Yol helper'ları
39
+ # ─────────────────────────────────────────────────────────────
40
+
41
+ def analyses_dir(project_id: str) -> Path:
42
+ p = storage.project_dir(project_id) / "analyses"
43
+ p.mkdir(parents=True, exist_ok=True)
44
+ return p
45
+
46
+
47
+ def active_pointer_path(project_id: str) -> Path:
48
+ return analyses_dir(project_id) / "active.json"
49
+
50
+
51
+ def analysis_dir(project_id: str, analysis_id: str) -> Path:
52
+ return analyses_dir(project_id) / analysis_id
53
+
54
+
55
+ # ─────────────────────────────────────────────────────────────
56
+ # CRUD
57
+ # ─────────────────────────────────────────────────────────────
58
+
59
+ def _write_meta(adir: Path, meta: dict[str, Any]) -> None:
60
+ (adir / "meta.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
61
+
62
+
63
+ def _read_meta(adir: Path) -> dict[str, Any]:
64
+ p = adir / "meta.json"
65
+ if not p.exists():
66
+ return {}
67
+ try:
68
+ return json.loads(p.read_text(encoding="utf-8"))
69
+ except Exception:
70
+ return {}
71
+
72
+
73
+ def create_analysis(project_id: str, method: str, label: Optional[str] = None) -> tuple[str, Path]:
74
+ """Yeni analiz oluştur — timestamp'li klasör + meta.json. Aktif yapılmaz.
75
+
76
+ Dönüş: (analysis_id, path)
77
+ """
78
+ now = time.time()
79
+ stamp = datetime.fromtimestamp(now).strftime("%Y%m%d_%H%M%S")
80
+ analysis_id = f"analysis_{stamp}_{method}"
81
+
82
+ # Çakışma durumunda sonek ekle
83
+ base = analyses_dir(project_id)
84
+ i = 1
85
+ while (base / analysis_id).exists():
86
+ analysis_id = f"analysis_{stamp}_{method}_{i}"
87
+ i += 1
88
+
89
+ target = analysis_dir(project_id, analysis_id)
90
+ target.mkdir(parents=True, exist_ok=False)
91
+
92
+ if not label:
93
+ label = f"{method.title()} — {datetime.fromtimestamp(now).strftime('%d.%m.%Y %H:%M')}"
94
+
95
+ meta = {
96
+ "id": analysis_id,
97
+ "created_at": now,
98
+ "method": method,
99
+ "label": label,
100
+ "file_count": 0,
101
+ "source": "user_run",
102
+ }
103
+ _write_meta(target, meta)
104
+ return analysis_id, target
105
+
106
+
107
+ def finalize_analysis(project_id: str, analysis_id: str) -> None:
108
+ """Analiz tamamlandığında file_count'u güncelle + aktif yap."""
109
+ adir = analysis_dir(project_id, analysis_id)
110
+ if not adir.exists():
111
+ return
112
+ meta = _read_meta(adir)
113
+ meta["file_count"] = sum(1 for p in adir.iterdir() if p.is_file() and p.name != "meta.json")
114
+ meta["completed_at"] = time.time()
115
+ _write_meta(adir, meta)
116
+ set_active_analysis(project_id, analysis_id)
117
+
118
+
119
+ def list_analyses(project_id: str) -> list[dict[str, Any]]:
120
+ """Tüm analizleri en yeniden eskiye sırala."""
121
+ out: list[dict[str, Any]] = []
122
+ active_id = get_active_analysis_id(project_id)
123
+ base = analyses_dir(project_id)
124
+ if not base.exists():
125
+ return out
126
+
127
+ for p in base.iterdir():
128
+ if not p.is_dir():
129
+ continue
130
+ # Trash klasörlerini (silinmiş ama henüz fiziksel temizlenmemiş) atla
131
+ if p.name.startswith("_deleted_"):
132
+ continue
133
+ meta = _read_meta(p)
134
+ # Soft-delete edilmiş analizleri atla (meta.json'da deleted: true)
135
+ if meta.get("deleted"):
136
+ continue
137
+ if not meta:
138
+ # meta.json yoksa basic info üret
139
+ meta = {
140
+ "id": p.name,
141
+ "created_at": p.stat().st_mtime,
142
+ "method": "unknown",
143
+ "label": p.name,
144
+ "file_count": sum(1 for f in p.iterdir() if f.is_file()),
145
+ }
146
+ meta["is_active"] = (meta.get("id") == active_id)
147
+ # Boyut bilgisi
148
+ try:
149
+ meta["total_size"] = sum(f.stat().st_size for f in p.iterdir() if f.is_file())
150
+ except Exception:
151
+ meta["total_size"] = 0
152
+ out.append(meta)
153
+
154
+ out.sort(key=lambda m: m.get("created_at", 0), reverse=True)
155
+ return out
156
+
157
+
158
+ def get_analysis_meta(project_id: str, analysis_id: str) -> Optional[dict[str, Any]]:
159
+ adir = analysis_dir(project_id, analysis_id)
160
+ if not adir.exists():
161
+ return None
162
+ meta = _read_meta(adir)
163
+ # Soft-deleted analizler "yok" gibi davranılır — tekrar silinemez
164
+ if meta.get("deleted"):
165
+ return None
166
+ meta["is_active"] = (meta.get("id") == get_active_analysis_id(project_id))
167
+ return meta or None
168
+
169
+
170
+ def delete_analysis(project_id: str, analysis_id: str) -> bool:
171
+ """Bir analizi sil — Windows-friendly **soft delete** stratejisi.
172
+
173
+ Windows'ta açık file handle'lar (pandas read_excel cache, openpyxl iç
174
+ state, vb.) dosyayı `unlink`/`rename` için kilitliyor. Bunu aşmak için:
175
+
176
+ 1. Aktif analiz siliniyorsa aktifliği başka analize taşı
177
+ 2. filter_engine DataFrame cache'i temizle + gc.collect()
178
+ 3. **Önce hızlı yol**: shutil.rmtree dene (1 retry).
179
+ 4. **Fallback (soft delete)**: meta.json'a `"deleted": true` flag'i yaz.
180
+ list_analyses() bunu filtreler; UI'da görünmez. Disk alanı sonraki
181
+ backend restart'ında `purge_soft_deleted()` ile temizlenir.
182
+
183
+ Soft-delete sayesinde DELETE hiçbir zaman 500 fırlatmaz; kullanıcı
184
+ deneyimi tutarlı kalır.
185
+ """
186
+ import gc
187
+ import time as _time
188
+
189
+ adir = analysis_dir(project_id, analysis_id)
190
+ if not adir.exists():
191
+ return False
192
+
193
+ active_id = get_active_analysis_id(project_id)
194
+
195
+ # 1) Aktif analiz siliniyorsa önce aktifliği taşı
196
+ if active_id == analysis_id:
197
+ remaining = [a for a in list_analyses(project_id) if a["id"] != analysis_id]
198
+ if remaining:
199
+ set_active_analysis(project_id, remaining[0]["id"])
200
+ else:
201
+ clear_active_analysis(project_id)
202
+
203
+ # 2) Filter cache'i temizle
204
+ try:
205
+ from services import filter_engine
206
+ filter_engine._DF_CACHE.clear()
207
+ except Exception:
208
+ pass
209
+ gc.collect()
210
+
211
+ # 3) Hızlı yol: gerçek silme dene (Windows'ta sıklıkla fail eder)
212
+ def _on_rmtree_error(func, path, exc_info):
213
+ try:
214
+ import os, stat
215
+ os.chmod(path, stat.S_IWRITE)
216
+ func(path)
217
+ except Exception:
218
+ raise
219
+
220
+ for _ in range(2):
221
+ try:
222
+ shutil.rmtree(adir, onerror=_on_rmtree_error)
223
+ return True
224
+ except OSError:
225
+ gc.collect()
226
+ _time.sleep(0.3)
227
+
228
+ # 4) Soft delete fallback — meta.json'a flag yaz
229
+ meta_path = adir / "meta.json"
230
+ try:
231
+ meta = _read_meta(adir)
232
+ except Exception:
233
+ meta = {}
234
+ meta["deleted"] = True
235
+ meta["deleted_at"] = _time.time()
236
+ try:
237
+ meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
238
+ except Exception:
239
+ # meta.json bile yazılamıyorsa gerçekten kilitli — başarısız
240
+ if active_id == analysis_id:
241
+ try:
242
+ set_active_analysis(project_id, analysis_id)
243
+ except Exception:
244
+ pass
245
+ return False
246
+ return True
247
+
248
+
249
+ def purge_soft_deleted(project_id: str) -> int:
250
+ """Soft-delete edilmiş ama henüz fiziksel olarak silinmemiş analiz
251
+ klasörlerini temizlemeyi dene. Backend startup'ta veya periyodik
252
+ çağrılabilir. Başarıyla silinen klasör sayısını döner."""
253
+ import gc
254
+ base = analyses_dir(project_id)
255
+ if not base.exists():
256
+ return 0
257
+ purged = 0
258
+ for p in base.iterdir():
259
+ if not p.is_dir():
260
+ continue
261
+ meta = _read_meta(p)
262
+ if not meta.get("deleted"):
263
+ continue
264
+ gc.collect()
265
+ try:
266
+ shutil.rmtree(p, ignore_errors=True)
267
+ if not p.exists():
268
+ purged += 1
269
+ except Exception:
270
+ pass
271
+ return purged
272
+
273
+
274
+ # ─────────────────────────────────────────────────────────────
275
+ # Active analysis
276
+ # ─────────────────────────────────────────────────────────────
277
+
278
+ def get_active_analysis_id(project_id: str) -> Optional[str]:
279
+ p = active_pointer_path(project_id)
280
+ if not p.exists():
281
+ return None
282
+ try:
283
+ data = json.loads(p.read_text(encoding="utf-8"))
284
+ return data.get("id")
285
+ except Exception:
286
+ return None
287
+
288
+
289
+ def set_active_analysis(project_id: str, analysis_id: str) -> None:
290
+ p = active_pointer_path(project_id)
291
+ p.parent.mkdir(parents=True, exist_ok=True)
292
+ p.write_text(json.dumps({"id": analysis_id, "set_at": time.time()}, indent=2), encoding="utf-8")
293
+
294
+
295
+ def clear_active_analysis(project_id: str) -> None:
296
+ p = active_pointer_path(project_id)
297
+ if p.exists():
298
+ p.unlink()
299
+
300
+
301
+ def get_active_analysis_dir(project_id: str) -> Optional[Path]:
302
+ """Aktif analizin klasör yolunu döndür.
303
+
304
+ Eğer aktif analiz yoksa, son yapılan analizi otomatik aktif yap.
305
+ Hiç analiz yoksa None döner.
306
+ """
307
+ active_id = get_active_analysis_id(project_id)
308
+ if active_id:
309
+ adir = analysis_dir(project_id, active_id)
310
+ if adir.exists():
311
+ return adir
312
+ # Aktif klasör silinmiş; pointer'ı temizle
313
+ clear_active_analysis(project_id)
314
+
315
+ # Aktif yoksa en yenisi
316
+ analyses = list_analyses(project_id)
317
+ if analyses:
318
+ latest = analyses[0]["id"]
319
+ set_active_analysis(project_id, latest)
320
+ return analysis_dir(project_id, latest)
321
+
322
+ return None
323
+
324
+
325
+ def work_dir(project_id: str) -> Path:
326
+ """Analiz-spesifik çıktıların (disambiguation önerileri, snapshot'lar, audit)
327
+ yazılacağı klasör = AKTİF analiz klasörü. Böylece her yeni merge (yeni analiz)
328
+ kendi temiz çalışma alanına sahip olur ve eski öneriler/snapshot'lar taşınmaz.
329
+
330
+ Hiç analiz yoksa proje köküne düşer (geriye uyumluluk; pratikte merge'siz bu
331
+ yollar çağrılmaz).
332
+ """
333
+ adir = get_active_analysis_dir(project_id)
334
+ return adir if adir is not None else storage.project_dir(project_id)
335
+
336
+
337
+ # ─────────────────────────────────────────────────────────────
338
+ # Dataset path resolver (filter_engine, merger için)
339
+ # ─────────────────────────────────────────────────────────────
340
+
341
+ # Aktif analiz klasöründeki ana dataset dosya adı (Smart Merge → merged.xlsx)
342
+ _DATASET_NAMES = (
343
+ "merged.xlsx",
344
+ )
345
+
346
+
347
+ def active_dataset_path(project_id: str) -> Optional[Path]:
348
+ """Aktif analizdeki ana birleştirilmiş dataset dosyasını döndür."""
349
+ adir = get_active_analysis_dir(project_id)
350
+ if adir is None:
351
+ return None
352
+ for name in _DATASET_NAMES:
353
+ p = adir / name
354
+ if p.exists():
355
+ return p
356
+ # Fallback: ilk *.xlsx (yan dosyaları atla)
357
+ SKIP = {"statistic.xlsx", "statistic_smart.xlsx", "match_audit.xlsx",
358
+ "conflict_log.xlsx", "borderline_queue.xlsx"}
359
+ for f in sorted(adir.iterdir()):
360
+ if (f.suffix.lower() == ".xlsx"
361
+ and f.name.lower() not in SKIP
362
+ and not f.name.lower().startswith("lost_")):
363
+ return f
364
+ return None
@@ -0,0 +1,340 @@
1
+ """Append-only audit log — kullanıcının her önemli işlemini kaydeder.
2
+
3
+ Her event JSON Lines (`operations.jsonl`) dosyasına yazılır:
4
+ {ts, kind, title, details, user_action, snapshot, before, after}
5
+
6
+ Kind örnekleri:
7
+ "upload" — dosya yüklendi
8
+ "convert" — CSV/TXT → XLSX
9
+ "merge" — simple / enhanced
10
+ "filter_save" — preset kaydedildi
11
+ "records_delete" — toplu silme
12
+ "enrich_api" — API enrichment çalıştı
13
+ "enrich_ml" — ML enrichment çalıştı
14
+ "disambiguate" — yazar/affiliation cluster uygulandı
15
+ "snapshot" — snapshot alındı
16
+ "export" — dosya export edildi
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import threading
23
+ import time
24
+ from pathlib import Path
25
+ from typing import Any, Optional
26
+
27
+ from services import storage
28
+
29
+
30
+ _LOCK = threading.Lock()
31
+
32
+ # analysis_id sentinel'leri — her analizin AYRI geçmişi için.
33
+ _AUTO = "__auto__" # write: kaydı aktif analize otomatik etiketle
34
+ _ALL = "__all__" # read: filtre yok (tüm proje)
35
+
36
+
37
+ def _log_path(project_id: str) -> Path:
38
+ return storage.project_dir(project_id) / "operations.jsonl"
39
+
40
+
41
+ def write(
42
+ project_id: str,
43
+ kind: str,
44
+ title: str,
45
+ *,
46
+ title_key: Optional[str] = None,
47
+ title_params: Optional[dict[str, Any]] = None,
48
+ details: Optional[dict[str, Any]] = None,
49
+ before: Optional[dict[str, Any]] = None,
50
+ after: Optional[dict[str, Any]] = None,
51
+ snapshot: Optional[str] = None,
52
+ user_action: Optional[str] = None,
53
+ analysis_id: Optional[str] = _AUTO,
54
+ ) -> dict[str, Any]:
55
+ """Yeni bir kayıt ekle. Hatalar sessizce yutulur — log bozulması ana akışı durdurmasın.
56
+
57
+ `title` — geriye dönük / markdown rapor için okunabilir metin (fallback).
58
+ `title_key` + `title_params` — UI display-time i18n için (frontend t(key, params)).
59
+ `analysis_id` — kaydın ait olduğu analiz (her analizin AYRI geçmişi). Varsayılan
60
+ ("__auto__") aktif analizi otomatik bulur; proje-seviye işlemler (upload/file
61
+ delete vb.) `analysis_id=None` geçer → tüm analizlerde "kurulum" olarak görünür.
62
+ """
63
+ if analysis_id == _AUTO:
64
+ try:
65
+ from services import analyses
66
+ analysis_id = analyses.get_active_analysis_id(project_id)
67
+ except Exception:
68
+ analysis_id = None
69
+ entry = {
70
+ "ts": time.time(),
71
+ "kind": kind,
72
+ "title": title,
73
+ "title_key": title_key,
74
+ "title_params": title_params or None,
75
+ "analysis_id": analysis_id,
76
+ "details": details or {},
77
+ "before": before,
78
+ "after": after,
79
+ "snapshot": snapshot,
80
+ "user_action": user_action,
81
+ }
82
+ try:
83
+ with _LOCK:
84
+ p = _log_path(project_id)
85
+ p.parent.mkdir(parents=True, exist_ok=True)
86
+ with p.open("a", encoding="utf-8") as f:
87
+ f.write(json.dumps(entry, ensure_ascii=False, default=str) + "\n")
88
+ except Exception:
89
+ pass
90
+ return entry
91
+
92
+
93
+ def read(
94
+ project_id: str,
95
+ limit: Optional[int] = None,
96
+ analysis_id: Optional[str] = _ALL,
97
+ ) -> list[dict[str, Any]]:
98
+ """Audit kayıtlarını oku.
99
+
100
+ `analysis_id`:
101
+ "__all__" → filtre yok (tüm proje geçmişi).
102
+ <id> / None → o analizin kayıtları + proje-seviye (analysis_id is None) kurulum
103
+ kayıtları (upload vb.) — "o analizde nelerin yapıldığı".
104
+ """
105
+ p = _log_path(project_id)
106
+ if not p.exists():
107
+ return []
108
+ out: list[dict[str, Any]] = []
109
+ try:
110
+ with p.open("r", encoding="utf-8") as f:
111
+ for line in f:
112
+ line = line.strip()
113
+ if not line:
114
+ continue
115
+ try:
116
+ out.append(json.loads(line))
117
+ except Exception:
118
+ continue
119
+ except Exception:
120
+ return []
121
+ if analysis_id != _ALL:
122
+ out = [e for e in out if e.get("analysis_id") == analysis_id or e.get("analysis_id") is None]
123
+ if limit and limit > 0:
124
+ out = out[-limit:]
125
+ return out
126
+
127
+
128
+ def delete_for_analysis(project_id: str, analysis_id: str) -> int:
129
+ """Bir analizin kayıtlarını sil (o analiz silinince çağrılır). Proje-seviye
130
+ (analysis_id is None) kurulum kayıtları ve diğer analizlerin kayıtları KORUNUR."""
131
+ p = _log_path(project_id)
132
+ if not p.exists():
133
+ return 0
134
+ entries = read(project_id) # tümü
135
+ kept = [e for e in entries if e.get("analysis_id") != analysis_id]
136
+ removed = len(entries) - len(kept)
137
+ if removed <= 0:
138
+ return 0
139
+ try:
140
+ with _LOCK:
141
+ with p.open("w", encoding="utf-8") as f:
142
+ for e in kept:
143
+ f.write(json.dumps(e, ensure_ascii=False, default=str) + "\n")
144
+ except Exception:
145
+ return 0
146
+ return removed
147
+
148
+
149
+ def clear(project_id: str) -> int:
150
+ """Tüm log'u sil. Geri dönüşü olmayan operasyon (kullanıcı onayıyla)."""
151
+ p = _log_path(project_id)
152
+ if not p.exists():
153
+ return 0
154
+ count = sum(1 for _ in p.open("r", encoding="utf-8"))
155
+ p.unlink()
156
+ return count
157
+
158
+
159
+ def summary(project_id: str) -> dict[str, Any]:
160
+ """Kaba özet — kategori bazlı sayım, ilk/son zaman."""
161
+ entries = read(project_id)
162
+ if not entries:
163
+ return {"total": 0, "by_kind": {}, "first_ts": None, "last_ts": None}
164
+ by_kind: dict[str, int] = {}
165
+ for e in entries:
166
+ by_kind[e.get("kind", "?")] = by_kind.get(e.get("kind", "?"), 0) + 1
167
+ return {
168
+ "total": len(entries),
169
+ "by_kind": by_kind,
170
+ "first_ts": entries[0].get("ts"),
171
+ "last_ts": entries[-1].get("ts"),
172
+ }
173
+
174
+
175
+ # ----- Markdown raporu -----
176
+
177
+ KIND_LABELS = {
178
+ "project_create": "📋 Proje oluşturma",
179
+ "upload": "📥 Dosya yükleme",
180
+ "convert": "🔄 Dönüşüm",
181
+ "merge": "🧩 Birleştirme",
182
+ "merge_borderline": "🔍 Borderline kararı",
183
+ "analysis_activate": "🎯 Aktif analiz değiştirildi",
184
+ "analysis_delete": "🗑️ Analiz silindi",
185
+ "filter_save": "⭐ Preset kaydı",
186
+ "records_delete": "🗑️ Kayıt silme",
187
+ "records_edit": "✏️ Kayıt düzenleme",
188
+ "enrich_api": "🌐 API zenginleştirme",
189
+ "enrich_ml": "🧠 ML zenginleştirme",
190
+ "enrich_field": "🔧 Alan zenginleştirme",
191
+ "enrich_selected": "🔧 Seçimsel zenginleştirme",
192
+ "disambiguate": "✨ Disambiguation",
193
+ "disambiguate_authors": "✨ Yazar Disambiguation",
194
+ "disambiguate_affiliations": "🏢 Affiliation Disambiguation",
195
+ "snapshot": "📸 Snapshot",
196
+ "snapshot_restore": "↩️ Snapshot geri yükleme",
197
+ "export": "📤 Export",
198
+ "report": "📄 Rapor",
199
+ }
200
+
201
+
202
+ def _render_smart_merge_md(details: dict) -> list[str]:
203
+ """Smart Merge audit entry için zengin Markdown bloğu."""
204
+ lines: list[str] = []
205
+ wos = details.get("wos_input", 0)
206
+ scp = details.get("scopus_input", 0)
207
+ merged = details.get("merged_count", 0)
208
+ matched = details.get("matched_pairs", 0)
209
+ borderline = details.get("borderline_count", 0)
210
+ conflict = details.get("conflict_count", 0)
211
+ lost_wos = details.get("lost_wos_count", 0)
212
+ lost_scp = details.get("lost_scopus_count", 0)
213
+ duration = details.get("duration_seconds")
214
+
215
+ # Akış özeti
216
+ lines.append("**Birleştirme Özeti:**")
217
+ lines.append("")
218
+ lines.append("| Metrik | Değer |")
219
+ lines.append("|---|---|")
220
+ lines.append(f"| Ham girdi | {wos} WoS + {scp} Scopus = **{wos + scp}** |")
221
+ lines.append(f"| Eşleştirilen çift | **{matched}** |")
222
+ lines.append(f"| Borderline (manuel onay bekleyen) | {borderline} |")
223
+ lines.append(f"| Eşleşmemiş WoS (lost) | {lost_wos} |")
224
+ lines.append(f"| Eşleşmemiş Scopus (lost) | {lost_scp} |")
225
+ lines.append(f"| Çıktı (benzersiz kayıt) | **{merged}** |")
226
+ dedup_rate = (wos + scp - merged) / (wos + scp) if (wos + scp) else 0
227
+ lines.append(f"| Tekilleştirme oranı | %{dedup_rate * 100:.1f} |")
228
+ if duration:
229
+ lines.append(f"| Süre | {duration:.1f} sn |")
230
+ lines.append("")
231
+
232
+ # Eşleşme aşamaları
233
+ stages = details.get("match_stages") or {}
234
+ if stages:
235
+ lines.append("**Eşleşme Aşamaları** _(Hammerton 2013 + Caputo 2024)_:")
236
+ lines.append("")
237
+ lines.append("| Aşama | Eşleşme |")
238
+ lines.append("|---|---|")
239
+ for stage, count in sorted(stages.items(), key=lambda x: -x[1]):
240
+ lines.append(f"| {stage} | {count} |")
241
+ lines.append("")
242
+
243
+ # Çakışma kaynak dağılımı (Caputo 2024)
244
+ field_dist = details.get("field_source_distribution") or {}
245
+ if field_dist:
246
+ total = sum(field_dist.values())
247
+ lines.append(f"**Alan Çakışma Çözümü** _({conflict} çakışma — Caputo 2024 sabit defaults)_:")
248
+ lines.append("")
249
+ lines.append("| Tercih Edilen Kaynak | Sayı | Yüzde |")
250
+ lines.append("|---|---|---|")
251
+ for src, count in sorted(field_dist.items(), key=lambda x: -x[1]):
252
+ pct = (count / total * 100) if total else 0
253
+ lines.append(f"| {src} | {count} | %{pct:.1f} |")
254
+ lines.append("")
255
+
256
+ # Üretilen dosyalar
257
+ files = details.get("output_files") or []
258
+ if files:
259
+ lines.append("**Üretilen Dosyalar:**")
260
+ for f in files:
261
+ lines.append(f"- `{f}`")
262
+ lines.append("")
263
+
264
+ return lines
265
+
266
+
267
+ def format_markdown_report(project_id: str, analysis_id: Optional[str] = _ALL) -> str:
268
+ import datetime as dt
269
+ meta = storage.get_project(project_id)
270
+ entries = read(project_id, analysis_id=analysis_id)
271
+ name = meta.name if meta else project_id
272
+
273
+ lines: list[str] = []
274
+ lines.append(f"# BibexPy v2 — Operasyon Raporu")
275
+ lines.append("")
276
+ lines.append(f"**Proje:** {name} (`{project_id}`) ")
277
+ lines.append(f"**Oluşturma:** {dt.datetime.now().strftime('%Y-%m-%d %H:%M')} ")
278
+ lines.append(f"**Toplam operasyon:** {len(entries)}")
279
+ lines.append("")
280
+
281
+ if not entries:
282
+ lines.append("_Henüz operasyon kaydı yok._")
283
+ return "\n".join(lines)
284
+
285
+ # Özet — filtrelenmiş kayıtlardan (aktif analiz geçmişi)
286
+ by_kind: dict[str, int] = {}
287
+ for e in entries:
288
+ by_kind[e.get("kind", "?")] = by_kind.get(e.get("kind", "?"), 0) + 1
289
+ lines.append("## Özet")
290
+ lines.append("")
291
+ lines.append("| Kategori | Sayı |")
292
+ lines.append("|---|---|")
293
+ for k, n in sorted(by_kind.items(), key=lambda x: -x[1]):
294
+ lines.append(f"| {KIND_LABELS.get(k, k)} | {n} |")
295
+ lines.append("")
296
+
297
+ lines.append("## Kronoloji")
298
+ lines.append("")
299
+ for i, e in enumerate(entries, 1):
300
+ ts = e.get("ts")
301
+ ts_str = dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else "—"
302
+ label = KIND_LABELS.get(e.get("kind", ""), e.get("kind", "?"))
303
+ title = e.get("title", "")
304
+ lines.append(f"### {i}. {label} — {title}")
305
+ lines.append(f"_{ts_str}_")
306
+ details = e.get("details") or {}
307
+
308
+ # Smart Merge için zengin format — JSON dump değil
309
+ if e.get("kind") == "merge" and details.get("method") == "smart":
310
+ lines.append("")
311
+ lines.extend(_render_smart_merge_md(details))
312
+ if e.get("snapshot"):
313
+ lines.append(f"- 📸 snapshot: `{e['snapshot']}`")
314
+ lines.append("")
315
+ continue
316
+
317
+ if details:
318
+ lines.append("")
319
+ for k, v in details.items():
320
+ if v is None or v == "":
321
+ continue
322
+ if isinstance(v, (list, dict)):
323
+ v = json.dumps(v, ensure_ascii=False)
324
+ if len(str(v)) > 200:
325
+ v = str(v)[:200] + "…"
326
+ lines.append(f"- **{k}:** {v}")
327
+ before = e.get("before")
328
+ after = e.get("after")
329
+ if before or after:
330
+ lines.append("")
331
+ if before:
332
+ lines.append(f"- _önce:_ `{json.dumps(before, ensure_ascii=False)}`")
333
+ if after:
334
+ lines.append(f"- _sonra:_ `{json.dumps(after, ensure_ascii=False)}`")
335
+ if e.get("snapshot"):
336
+ lines.append(f"- 📸 snapshot: `{e['snapshot']}`")
337
+ if e.get("user_action"):
338
+ lines.append(f"- 👤 kullanıcı: _{e['user_action']}_")
339
+ lines.append("")
340
+ return "\n".join(lines)