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,241 @@
1
+ """Veri hazırlama orkestratörü.
2
+
3
+ Tek butonla ham CSV/TXT'leri otomatik XLSX'e çevirir.
4
+ Kullanıcı dönüşüm seçimleri yapmasına gerek kalmaz.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ from fastapi import APIRouter, HTTPException
10
+ from pydantic import BaseModel
11
+
12
+ from services import audit, converter, storage
13
+
14
+ router = APIRouter(prefix="/projects/{project_id}/prepare", tags=["prepare"])
15
+
16
+
17
+ class ProcessedFile(BaseModel):
18
+ name: str
19
+ size: int
20
+ mtime: float
21
+ kind: str # "scopus" | "wos" | "other"
22
+
23
+
24
+ class PrepareReport(BaseModel):
25
+ scopus_xlsx: str | None = None
26
+ wos_xlsx: str | None = None
27
+ raw_csv_count: int = 0
28
+ raw_txt_count: int = 0
29
+ skipped: list[str] = []
30
+ messages: list[str] = []
31
+ # Tüm processed/ dosyaları detayıyla (UI'da liste için)
32
+ processed_files: list[ProcessedFile] = []
33
+ # Hazırlığın güncelliği — bir raw dosya processed'tan yeni ise stale
34
+ stale: bool = False
35
+
36
+
37
+ def _classify_processed(name: str) -> str:
38
+ n = name.lower()
39
+ if "scopus" in n or "scp" in n:
40
+ return "scopus"
41
+ if "wos" in n:
42
+ return "wos"
43
+ return "other"
44
+
45
+
46
+ @router.get("/status", response_model=PrepareReport)
47
+ def status(project_id: str):
48
+ """Hazırlık durumunu döndürür — hangi XLSX'ler hazır, hangi ham dosyalar var."""
49
+ meta = storage.get_project(project_id)
50
+ if meta is None:
51
+ raise HTTPException(404, "project_not_found")
52
+ raw = storage.project_dir(project_id) / "raw"
53
+ processed = storage.project_dir(project_id) / "processed"
54
+
55
+ report = PrepareReport()
56
+ raw_max_mtime = 0.0
57
+ if raw.exists():
58
+ for f in raw.iterdir():
59
+ if not f.is_file():
60
+ continue
61
+ ext = f.suffix.lower()
62
+ if ext == ".csv":
63
+ report.raw_csv_count += 1
64
+ elif ext in (".txt", ".isi"):
65
+ report.raw_txt_count += 1
66
+ raw_max_mtime = max(raw_max_mtime, f.stat().st_mtime)
67
+
68
+ processed_min_mtime = float("inf")
69
+ if processed.exists():
70
+ for f in sorted(processed.iterdir()):
71
+ if not f.is_file():
72
+ continue
73
+ st = f.stat()
74
+ kind = _classify_processed(f.name)
75
+ report.processed_files.append(ProcessedFile(
76
+ name=f.name,
77
+ size=st.st_size,
78
+ mtime=st.st_mtime,
79
+ kind=kind,
80
+ ))
81
+ if kind == "scopus" and report.scopus_xlsx is None:
82
+ report.scopus_xlsx = f.name
83
+ elif kind == "wos" and report.wos_xlsx is None:
84
+ report.wos_xlsx = f.name
85
+ processed_min_mtime = min(processed_min_mtime, st.st_mtime)
86
+
87
+ # Stale: en yeni raw dosya, en eski processed dosyadan daha sonra ise hazırlık güncel değil
88
+ if report.processed_files and raw_max_mtime > 0:
89
+ report.stale = raw_max_mtime > processed_min_mtime
90
+ return report
91
+
92
+
93
+ @router.post("", response_model=PrepareReport)
94
+ def prepare(project_id: str):
95
+ """Tüm ham dosyaları otomatik XLSX'e çevirir.
96
+
97
+ - Tüm Scopus CSV'leri → `scopus_merged.xlsx`
98
+ - Tüm WoS TXT'leri → `wos_merged.xlsx`
99
+ """
100
+ meta = storage.get_project(project_id)
101
+ if meta is None:
102
+ raise HTTPException(404, "project_not_found")
103
+
104
+ raw = storage.project_dir(project_id) / "raw"
105
+ if not raw.exists():
106
+ raise HTTPException(400, "no_raw_files")
107
+
108
+ csv_files = [f.name for f in raw.iterdir() if f.suffix.lower() == ".csv"]
109
+ txt_files = [f.name for f in raw.iterdir() if f.suffix.lower() in (".txt", ".isi")]
110
+
111
+ report = PrepareReport(raw_csv_count=len(csv_files), raw_txt_count=len(txt_files))
112
+
113
+ if not csv_files and not txt_files:
114
+ raise HTTPException(400, "no_files_to_process")
115
+
116
+ if csv_files:
117
+ try:
118
+ # Önce eski adlandırma (scopus_merged.xlsx) varsa temizle — yenisini scopus.xlsx olarak yazacağız
119
+ old_scp = storage.project_dir(project_id) / "processed" / "scopus_merged.xlsx"
120
+ if old_scp.exists():
121
+ try:
122
+ old_scp.unlink()
123
+ except Exception:
124
+ pass
125
+ out = converter.csv_to_xlsx(project_id, csv_files, "scopus.xlsx")
126
+ report.scopus_xlsx = out.name
127
+ report.messages.append(f"Scopus: {len(csv_files)} CSV → {out.name}")
128
+ except Exception as e:
129
+ report.skipped.append(f"Scopus CSV dönüşümü: {e}")
130
+
131
+ if txt_files:
132
+ try:
133
+ old_wos = storage.project_dir(project_id) / "processed" / "wos_merged.xlsx"
134
+ if old_wos.exists():
135
+ try:
136
+ old_wos.unlink()
137
+ except Exception:
138
+ pass
139
+ out = converter.wos_to_xlsx(project_id, txt_files, "wos.xlsx")
140
+ report.wos_xlsx = out.name
141
+ report.messages.append(f"WoS: {len(txt_files)} TXT → {out.name}")
142
+ except Exception as e:
143
+ report.skipped.append(f"WoS TXT dönüşümü: {e}")
144
+
145
+ audit.write(
146
+ project_id,
147
+ kind="convert",
148
+ title=f"Otomatik hazırlık — {len(csv_files)} CSV + {len(txt_files)} TXT",
149
+ title_key="audit.titles.autoPrepare",
150
+ title_params={"csv": len(csv_files), "txt": len(txt_files)},
151
+ details={
152
+ "csv_files": len(csv_files),
153
+ "txt_files": len(txt_files),
154
+ "scopus_xlsx": report.scopus_xlsx,
155
+ "wos_xlsx": report.wos_xlsx,
156
+ "messages": report.messages,
157
+ "skipped": report.skipped,
158
+ },
159
+ user_action="auto_prepare",
160
+ )
161
+ return report
162
+
163
+
164
+ class ResetResult(BaseModel):
165
+ ok: bool = True
166
+ deleted_processed: int = 0
167
+
168
+
169
+ @router.post("/reset", response_model=ResetResult)
170
+ def reset(project_id: str):
171
+ """Veri hazırlığını sıfırla.
172
+
173
+ Kullanıcının "Yeni veri ekle" akışı: sadece hazırlanmış birleşik Excel
174
+ dosyaları (`processed/scopus_merged.xlsx`, `processed/wos_merged.xlsx`)
175
+ silinir. Ham yüklenen dosyalar (`raw/`) **dokunulmaz** — kullanıcı bunlara
176
+ yeni dosyalar ekleyip tekrar hazırlayacak. Analiz klasörleri (`analyses/`)
177
+ da silinmez; geçmiş birleştirmeler korunur.
178
+ """
179
+ meta = storage.get_project(project_id)
180
+ if meta is None:
181
+ raise HTTPException(404, "project_not_found")
182
+
183
+ root = storage.project_dir(project_id)
184
+ processed = root / "processed"
185
+
186
+ deleted_processed = 0
187
+
188
+ if processed.exists():
189
+ for f in list(processed.iterdir()):
190
+ if f.is_file():
191
+ try:
192
+ f.unlink()
193
+ deleted_processed += 1
194
+ except Exception:
195
+ pass
196
+
197
+ audit.write(
198
+ project_id,
199
+ kind="upload",
200
+ title=f"Hazırlık sıfırlandı — {deleted_processed} işlenmiş dosya silindi (ham dosyalar korundu)",
201
+ title_key="audit.titles.prepareReset",
202
+ title_params={"n": deleted_processed},
203
+ details={
204
+ "deleted_processed_files": deleted_processed,
205
+ },
206
+ user_action="prepare_reset",
207
+ )
208
+
209
+ return ResetResult(ok=True, deleted_processed=deleted_processed)
210
+
211
+
212
+ def _safe_name(name: str) -> str:
213
+ from pathlib import Path as _P
214
+ base = _P(name).name
215
+ if not base or base in {".", ".."}:
216
+ raise HTTPException(400, f"invalid_filename: {name!r}")
217
+ return base
218
+
219
+
220
+ @router.delete("/processed/{filename}", status_code=204)
221
+ def delete_processed_file(project_id: str, filename: str):
222
+ """Tek bir hazırlanmış dosyayı sil. Ham dosyalar ve analiz klasörleri korunur."""
223
+ meta = storage.get_project(project_id)
224
+ if meta is None:
225
+ raise HTTPException(404, "project_not_found")
226
+ name = _safe_name(filename)
227
+ target = storage.project_dir(project_id) / "processed" / name
228
+ if not target.exists() or not target.is_file():
229
+ raise HTTPException(404, "file_not_found")
230
+ size = target.stat().st_size
231
+ target.unlink()
232
+ audit.write(
233
+ project_id,
234
+ kind="upload",
235
+ title=f"Hazırlanmış dosya silindi: {name}",
236
+ title_key="audit.titles.processedFileDeleted",
237
+ title_params={"name": name},
238
+ details={"name": name, "size": size, "folder": "processed", "action": "delete"},
239
+ user_action="delete_processed_file",
240
+ )
241
+ return None
@@ -0,0 +1,49 @@
1
+ from fastapi import APIRouter, HTTPException, status
2
+
3
+ from models.project import ProjectCreate, ProjectMeta
4
+ from services import audit, storage
5
+
6
+ router = APIRouter(prefix="/projects", tags=["projects"])
7
+
8
+
9
+ @router.get("", response_model=list[ProjectMeta])
10
+ def list_projects():
11
+ return storage.list_projects()
12
+
13
+
14
+ @router.post("", response_model=ProjectMeta, status_code=status.HTTP_201_CREATED)
15
+ def create_project(payload: ProjectCreate):
16
+ meta = storage.create_project(payload.name, payload.description)
17
+ # Sürecin ilk adımı — günlüğün en başına "proje oluşturuldu" kaydı düşülür.
18
+ audit.write(
19
+ meta.id,
20
+ kind="project_create",
21
+ title=f"Proje oluşturuldu: {meta.name}",
22
+ title_key="audit.titles.projectCreated",
23
+ title_params={"name": meta.name},
24
+ details={"name": meta.name, "description": payload.description or ""},
25
+ user_action="create_project",
26
+ )
27
+ return meta
28
+
29
+
30
+ @router.get("/{project_id}", response_model=ProjectMeta)
31
+ def get_project(project_id: str):
32
+ meta = storage.get_project(project_id)
33
+ if meta is None:
34
+ raise HTTPException(404, "project_not_found")
35
+ return meta
36
+
37
+
38
+ @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
39
+ def delete_project(project_id: str):
40
+ if storage.get_project(project_id) is None:
41
+ raise HTTPException(404, "project_not_found")
42
+ try:
43
+ storage.delete_project(project_id)
44
+ except Exception as e:
45
+ # Yakalanmazsa 500 ServerErrorMiddleware'den döner ve CORS başlığı taşımaz
46
+ # (tarayıcıda yanıltıcı "CORS" hatası). HTTPException iç katmandan geçip
47
+ # CORS başlığı alır ve kullanıcıya gerçek nedeni gösterir.
48
+ raise HTTPException(409, f"project_delete_failed: {e}")
49
+ return None
@@ -0,0 +1,288 @@
1
+ """Veri kalitesi & grafik endpoint'leri.
2
+
3
+ UI bu endpoint'leri:
4
+ - Dashboard panelinde alan-bazlı doluluk yüzdelerini göstermek için
5
+ - Records sayfası üstündeki chart'lar için kullanır.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import time
12
+ import uuid
13
+ from pathlib import Path
14
+
15
+ from fastapi import APIRouter, BackgroundTasks, HTTPException
16
+ from fastapi.responses import FileResponse
17
+ import pandas as pd
18
+
19
+ from config import settings
20
+ from services import analyses, audit, filter_engine, storage
21
+
22
+
23
+ router = APIRouter(prefix="/projects/{project_id}/quality", tags=["quality"])
24
+
25
+
26
+ # Kalite panelinde izlenen alanlar — VOSviewer & Biblioshiny analizlerinin
27
+ # kullandığı sütunlara hizalı (kritiklik hint metninde). Bir alan yalnız sütunu
28
+ # merged dataset'te varsa gösterilir (available=True). Label = i18n fallback'i;
29
+ # UI gerçek etiketi recordDetail.fields.{kod}'tan alır.
30
+ QUALITY_FIELDS = [
31
+ # ── Tier 1 — kritik (eş-yazarlık / atıf ağları + Biblioshiny çekirdek) ──
32
+ ("AU", "Authors", "Critical: author & co-authorship analysis unit"),
33
+ ("PY", "Year", "Critical: temporal analysis / production over time"),
34
+ ("SO", "Source/Journal", "Critical: source coupling, Bradford, journal analysis"),
35
+ ("TC", "Times Cited", "Critical: citation / impact analysis"),
36
+ ("CR", "Cited References", "Critical: co-citation & bibliographic coupling"),
37
+ # ── Tier 2 — önemli ──
38
+ ("TI", "Title", "Important: term map, record identity"),
39
+ ("DE", "Author Keywords", "Important: co-word / keyword co-occurrence"),
40
+ ("ID", "Keywords Plus", "Important: co-word (WoS-generated)"),
41
+ ("C1", "Addresses", "Important: country & institution collaboration"),
42
+ # ── Tier 3 — faydalı ──
43
+ ("AB", "Abstract", "Useful: term map, ML & disambiguation input"),
44
+ ("AF", "Author Affiliations", "Useful: author-institution pairs"),
45
+ ("WC", "WoS Categories", "Useful: thematic categorization"),
46
+ ("SC", "Subject Categories", "Useful: higher-level categorization"),
47
+ ("DI", "DOI", "Useful: citability, enrichment key"),
48
+ ("LA", "Language", "Useful: language filtering"),
49
+ ]
50
+
51
+ # Bibliometrik AĞIRLIK — health_score, alanları analitik önemine göre ağırlıklandırır
52
+ # (alttaki bar "ne kadarı boş"u; health "veri bibliometrik analiz için ne kadar değerli"yi
53
+ # gösterir). Ağırlıklar VOSviewer/Biblioshiny kritikliğinden (Tier 1/2/3) türetilmiştir:
54
+ # Tier 1 (kritik) = 3 : olmazsa ana ağ analizleri (co-authorship, co-citation, kaynak,
55
+ # atıf, zaman) kurulamaz.
56
+ # Tier 2 (önemli) = 2 : co-word, ülke/kurum işbirliği, başlık-terim haritaları.
57
+ # Tier 3 (faydalı)= 1 : tamamlayıcı / filtreleme / kimlik alanları.
58
+ FIELD_WEIGHTS = {
59
+ "AU": 3, "PY": 3, "SO": 3, "TC": 3, "CR": 3, # Tier 1
60
+ "TI": 2, "DE": 2, "ID": 2, "C1": 2, # Tier 2
61
+ "AB": 1, "AF": 1, "WC": 1, "SC": 1, "DI": 1, "LA": 1, # Tier 3
62
+ }
63
+
64
+
65
+ def _is_filled(series: pd.Series) -> pd.Series:
66
+ s = series.astype(str).str.strip()
67
+ return (s != "") & (s.str.upper() != "NAN") & s.notna()
68
+
69
+
70
+ def _compute_stats(df: pd.DataFrame) -> dict:
71
+ """Alan-bazlı doluluk + ağırlıklı health skoru + DB dağılımı (stats ve overview ortak)."""
72
+ total = int(len(df))
73
+ fields = []
74
+ for code, label, hint in QUALITY_FIELDS:
75
+ if code not in df.columns:
76
+ fields.append({
77
+ "field": code, "label": label, "hint": hint,
78
+ "total": total, "filled": 0, "missing": total,
79
+ "fill_rate": 0.0, "available": False,
80
+ })
81
+ continue
82
+ filled_mask = _is_filled(df[code])
83
+ filled = int(filled_mask.sum())
84
+ fields.append({
85
+ "field": code, "label": label, "hint": hint,
86
+ "total": total, "filled": filled, "missing": total - filled,
87
+ "fill_rate": (filled / total) if total else 0.0,
88
+ "available": True,
89
+ })
90
+
91
+ num = sum(f["fill_rate"] * FIELD_WEIGHTS.get(f["field"], 1) for f in fields if f["available"])
92
+ den = sum(FIELD_WEIGHTS.get(f["field"], 1) for f in fields if f["available"])
93
+ health = float(num / den) if den else 0.0
94
+
95
+ db_dist = {}
96
+ if "DB" in df.columns:
97
+ db_dist = df["DB"].astype(str).str.strip().value_counts().head(10).to_dict()
98
+ db_dist = {str(k): int(v) for k, v in db_dist.items()}
99
+
100
+ return {
101
+ "total_records": total,
102
+ "health_score": health,
103
+ "fields": fields,
104
+ "db_distribution": db_dist,
105
+ }
106
+
107
+
108
+ @router.get("/last-fill-report")
109
+ def get_last_fill_report(project_id: str):
110
+ """En son 'Fill all' (fill_all) işleminin özetini döndür — sayfa yenilense bile
111
+ rapor ekranda kalsın diye. Hiç çalıştırılmamışsa {"report": None}.
112
+
113
+ Audit'te fill_all kaydı kind='enrich_api' + details.method=='fill_all' ile tutulur.
114
+ """
115
+ if storage.get_project(project_id) is None:
116
+ raise HTTPException(404, "project_not_found")
117
+ # Rapor YALNIZ mevcut aktif analize ait olmalı — yeni bir merge (yeni analiz)
118
+ # eski enrichment raporunu taşımasın. Aktif analizden farklı (veya analysis_id
119
+ # taşımayan eski) kayıtlar gösterilmez.
120
+ active_id = analyses.get_active_analysis_id(project_id)
121
+ for entry in reversed(audit.read(project_id)):
122
+ d = entry.get("details") or {}
123
+ if entry.get("kind") == "enrich_api" and d.get("method") == "fill_all":
124
+ if active_id is not None and d.get("analysis_id") != active_id:
125
+ continue # başka (eski) analizin raporu — atla
126
+ return {
127
+ "report": {
128
+ "ts": entry.get("ts"),
129
+ "enriched": d.get("enriched", 0),
130
+ "api": d.get("api"),
131
+ "doi": d.get("doi"),
132
+ "fill_rate_before": d.get("fill_rate_before"),
133
+ "fill_rate_after": d.get("fill_rate_after"),
134
+ "per_field_fill": d.get("per_field_fill"),
135
+ "snapshot": d.get("snapshot") or entry.get("snapshot"),
136
+ }
137
+ }
138
+ return {"report": None}
139
+
140
+
141
+ @router.get("/stats")
142
+ def get_quality_stats(project_id: str):
143
+ """Her alan için doluluk %, eksik sayısı, top değerler.
144
+
145
+ health_score = bibliometrik AĞIRLIKLI doluluk ortalaması (kritik alanlar düşükse
146
+ skor sert düşer; tamamlayıcı alanlar eksikse az etkilenir). Bkz. _compute_stats.
147
+ """
148
+ if storage.get_project(project_id) is None:
149
+ raise HTTPException(404, "project_not_found")
150
+ try:
151
+ df = filter_engine.load_merged(project_id)
152
+ except FileNotFoundError as e:
153
+ raise HTTPException(409, str(e))
154
+ return _compute_stats(df)
155
+
156
+
157
+ def _overview_cleanup(path: Path) -> None:
158
+ try:
159
+ if path.is_dir():
160
+ shutil.rmtree(path, ignore_errors=True)
161
+ except Exception:
162
+ pass
163
+
164
+
165
+ @router.get("/overview")
166
+ def download_overview(project_id: str, background_tasks: BackgroundTasks, fmt: str = "csv"):
167
+ """Genel Bakış tablosunu indir (Biblioshiny-tarzı özet) — CSV veya XLSX.
168
+
169
+ Rapora yapıştırılabilir tablo: alan kodu · etiket · toplam · dolu · eksik · doluluk%.
170
+ XLSX'te ayrıca 'Summary' sayfası (toplam kayıt, health skoru, DB dağılımı) yer alır.
171
+ PNG çıktısı istemci tarafında (html-to-image) üretilir; backend yalnız tabloyu verir.
172
+ """
173
+ if storage.get_project(project_id) is None:
174
+ raise HTTPException(404, "project_not_found")
175
+ if fmt not in ("csv", "xlsx"):
176
+ raise HTTPException(400, "invalid_format")
177
+ try:
178
+ df = filter_engine.load_merged(project_id)
179
+ except FileNotFoundError as e:
180
+ raise HTTPException(409, str(e))
181
+
182
+ stats = _compute_stats(df)
183
+ fields_table = pd.DataFrame([
184
+ {
185
+ "Field": f["field"],
186
+ "Label": f["label"],
187
+ "Total": f["total"],
188
+ "Filled": f["filled"],
189
+ "Missing": f["missing"],
190
+ "Fill rate (%)": round(f["fill_rate"] * 100, 1),
191
+ }
192
+ for f in stats["fields"] if f["available"]
193
+ ])
194
+
195
+ stamp = time.strftime("%Y%m%d_%H%M%S")
196
+ workdir = settings.storage_path / "tools_tmp" / uuid.uuid4().hex[:12]
197
+ workdir.mkdir(parents=True, exist_ok=True)
198
+ background_tasks.add_task(_overview_cleanup, workdir)
199
+ out_path = workdir / f"data_health_{stamp}.{fmt}"
200
+
201
+ if fmt == "csv":
202
+ # utf-8-sig → Excel Türkçe karakterleri doğru açsın.
203
+ fields_table.to_csv(out_path, index=False, encoding="utf-8-sig")
204
+ else:
205
+ summary_rows = [
206
+ {"Metric": "Total records", "Value": stats["total_records"]},
207
+ {"Metric": "Health score (%)", "Value": round(stats["health_score"] * 100, 1)},
208
+ ]
209
+ for db, n in (stats.get("db_distribution") or {}).items():
210
+ summary_rows.append({"Metric": f"Database · {db}", "Value": n})
211
+ with pd.ExcelWriter(out_path, engine="openpyxl") as xw:
212
+ pd.DataFrame(summary_rows).to_excel(xw, sheet_name="Summary", index=False)
213
+ fields_table.to_excel(xw, sheet_name="Fields", index=False)
214
+
215
+ media = "text/csv" if fmt == "csv" else \
216
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
217
+ return FileResponse(path=str(out_path), filename=out_path.name, media_type=media)
218
+
219
+
220
+ @router.get("/charts")
221
+ def get_charts(project_id: str):
222
+ """Görseller için chart verileri."""
223
+ if storage.get_project(project_id) is None:
224
+ raise HTTPException(404, "project_not_found")
225
+ try:
226
+ df = filter_engine.load_merged(project_id)
227
+ except FileNotFoundError as e:
228
+ raise HTTPException(409, str(e))
229
+
230
+ out: dict = {}
231
+
232
+ # Yıl histogramı + doc_type ile breakdown
233
+ if "PY" in df.columns:
234
+ years = pd.to_numeric(df["PY"], errors="coerce").dropna().astype(int)
235
+ if len(years):
236
+ year_counts = years.value_counts().sort_index()
237
+ out["year_histogram"] = [{"year": int(y), "count": int(c)} for y, c in year_counts.items()]
238
+
239
+ # Yıl × doc type cross-tab (stacked bar için)
240
+ if "DT" in df.columns:
241
+ df2 = df.copy()
242
+ df2["_y"] = pd.to_numeric(df2["PY"], errors="coerce")
243
+ df2 = df2.dropna(subset=["_y"])
244
+ df2["_y"] = df2["_y"].astype(int)
245
+ # En sık 5 tipi al
246
+ top_types = df2["DT"].astype(str).str.upper().value_counts().head(5).index.tolist()
247
+ ct = pd.crosstab(df2["_y"], df2["DT"].astype(str).str.upper())
248
+ ct = ct[[c for c in top_types if c in ct.columns]]
249
+ out["year_by_doctype"] = {
250
+ "types": top_types,
251
+ "data": [
252
+ {"year": int(y), **{t: int(ct.loc[y].get(t, 0)) for t in top_types}}
253
+ for y in ct.index
254
+ ],
255
+ }
256
+
257
+ # Top 15 dergi (SO)
258
+ if "SO" in df.columns:
259
+ so_counts = df["SO"].astype(str).str.strip().replace("", pd.NA).dropna().value_counts().head(15)
260
+ out["top_journals"] = [{"name": str(k), "count": int(v)} for k, v in so_counts.items()]
261
+
262
+ # Doc type dağılımı (pie)
263
+ if "DT" in df.columns:
264
+ dt_counts = df["DT"].astype(str).str.strip().str.upper().replace("", pd.NA).dropna().value_counts().head(10)
265
+ out["doc_types"] = [{"name": str(k), "count": int(v)} for k, v in dt_counts.items()]
266
+
267
+ # Dil dağılımı
268
+ if "LA" in df.columns:
269
+ la_counts = df["LA"].astype(str).str.strip().str.upper().replace("", pd.NA).dropna().value_counts().head(10)
270
+ out["languages"] = [{"name": str(k), "count": int(v)} for k, v in la_counts.items()]
271
+
272
+ # Atıf dağılımı (histogram bucketları)
273
+ if "TC" in df.columns:
274
+ tc = pd.to_numeric(df["TC"], errors="coerce").dropna().astype(int)
275
+ if len(tc):
276
+ # 0, 1-5, 6-10, 11-25, 26-50, 51-100, 100+
277
+ buckets = [(0, 0), (1, 5), (6, 10), (11, 25), (26, 50), (51, 100), (101, 10**9)]
278
+ labels = ["0", "1-5", "6-10", "11-25", "26-50", "51-100", "100+"]
279
+ data = []
280
+ for (lo, hi), lab in zip(buckets, labels):
281
+ if lo == 0 and hi == 0:
282
+ n = int((tc == 0).sum())
283
+ else:
284
+ n = int(((tc >= lo) & (tc <= hi)).sum())
285
+ data.append({"bucket": lab, "count": n})
286
+ out["citation_buckets"] = data
287
+
288
+ return out