BibexPy 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bibex_core/MergeDB.py +1189 -0
- bibex_core/__init__.py +13 -0
- bibex_core/modules/__init__.py +35 -0
- bibex_core/modules/api_utils.py +1172 -0
- bibex_core/modules/c1_utils.py +423 -0
- bibex_core/modules/file_utils.py +71 -0
- bibex_core/modules/ml_utils.py +332 -0
- bibex_core/modules/post_process.py +50 -0
- bibex_core/modules/stats_utils.py +216 -0
- bibex_core/scp2xlsx.py +235 -0
- bibex_core/wos2xlsx.py +159 -0
- bibex_core/xlsx2vos.py +185 -0
- bibexpy/__init__.py +9 -0
- bibexpy/__main__.py +6 -0
- bibexpy/_server/__init__.py +1 -0
- bibexpy/_server/config.py +92 -0
- bibexpy/_server/jobs/__init__.py +3 -0
- bibexpy/_server/jobs/runner.py +301 -0
- bibexpy/_server/main.py +211 -0
- bibexpy/_server/models/__init__.py +0 -0
- bibexpy/_server/models/project.py +25 -0
- bibexpy/_server/routers/__init__.py +0 -0
- bibexpy/_server/routers/audit.py +65 -0
- bibexpy/_server/routers/convert.py +101 -0
- bibexpy/_server/routers/disambiguate.py +177 -0
- bibexpy/_server/routers/downloads.py +26 -0
- bibexpy/_server/routers/enrich.py +42 -0
- bibexpy/_server/routers/export.py +116 -0
- bibexpy/_server/routers/export_folder.py +97 -0
- bibexpy/_server/routers/filter.py +131 -0
- bibexpy/_server/routers/jobs.py +72 -0
- bibexpy/_server/routers/merge.py +437 -0
- bibexpy/_server/routers/prepare.py +241 -0
- bibexpy/_server/routers/projects.py +49 -0
- bibexpy/_server/routers/quality.py +288 -0
- bibexpy/_server/routers/records.py +204 -0
- bibexpy/_server/routers/report.py +111 -0
- bibexpy/_server/routers/settings.py +383 -0
- bibexpy/_server/routers/system.py +209 -0
- bibexpy/_server/routers/tools.py +257 -0
- bibexpy/_server/routers/upload.py +110 -0
- bibexpy/_server/services/__init__.py +0 -0
- bibexpy/_server/services/analyses.py +364 -0
- bibexpy/_server/services/audit.py +340 -0
- bibexpy/_server/services/bibex_adapter.py +47 -0
- bibexpy/_server/services/bibtex_writer.py +104 -0
- bibexpy/_server/services/converter.py +205 -0
- bibexpy/_server/services/disambiguation/__init__.py +19 -0
- bibexpy/_server/services/disambiguation/blocking.py +581 -0
- bibexpy/_server/services/disambiguation/cache.py +47 -0
- bibexpy/_server/services/disambiguation/deepseek_client.py +203 -0
- bibexpy/_server/services/disambiguation/orcid.py +261 -0
- bibexpy/_server/services/disambiguation/pipeline.py +985 -0
- bibexpy/_server/services/disambiguation/similarity.py +91 -0
- bibexpy/_server/services/enricher.py +338 -0
- bibexpy/_server/services/exporter.py +112 -0
- bibexpy/_server/services/filter_engine.py +300 -0
- bibexpy/_server/services/merger.py +202 -0
- bibexpy/_server/services/methodology.py +102 -0
- bibexpy/_server/services/report_export.py +300 -0
- bibexpy/_server/services/ris_writer.py +62 -0
- bibexpy/_server/services/smart_merger.py +967 -0
- bibexpy/_server/services/storage.py +165 -0
- bibexpy/_web/404/index.html +1 -0
- bibexpy/_web/404.html +1 -0
- bibexpy/_web/_next/static/chunks/117-7ad5186126fa076d.js +2 -0
- bibexpy/_web/_next/static/chunks/156-75beffb8ae92a7f3.js +1 -0
- bibexpy/_web/_next/static/chunks/169-301c04fcec22686f.js +4 -0
- bibexpy/_web/_next/static/chunks/2-ecc1c36a5632ac61.js +1 -0
- bibexpy/_web/_next/static/chunks/244-be343e0f8aa00fb5.js +1 -0
- bibexpy/_web/_next/static/chunks/367-b7457c2b95f6932b.js +1 -0
- bibexpy/_web/_next/static/chunks/382-9cd163eed632e241.js +1 -0
- bibexpy/_web/_next/static/chunks/392-afa8bfa4c1a9893d.js +1 -0
- bibexpy/_web/_next/static/chunks/507-63607ded9f83c1aa.js +1 -0
- bibexpy/_web/_next/static/chunks/624-cae565632f47217d.js +1 -0
- bibexpy/_web/_next/static/chunks/739-a00c6b9c0f11813e.js +1 -0
- bibexpy/_web/_next/static/chunks/862-8e1bd5f93c082c02.js +1 -0
- bibexpy/_web/_next/static/chunks/app/_not-found/page-873ba59b5eb7c342.js +1 -0
- bibexpy/_web/_next/static/chunks/app/layout-0d5fc68ac8971a04.js +1 -0
- bibexpy/_web/_next/static/chunks/app/page-cf2bea5edc74459a.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/convert/page-ab6dd645ae113b64.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/disambiguate/page-dd98482cc47344e9.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/enrich/page-4727e431468514e9.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/export/page-f9005d164bb22cb0.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/layout-07755fda55b3ae1f.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/merge/page-45b95318ea7beccf.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/records/page-bfad80457da9eb0b.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/report/page-f05767e74158e995.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/[id]/upload/page-8df78ca9cdecbcb8.js +1 -0
- bibexpy/_web/_next/static/chunks/app/projects/page-99960711ac6a13e9.js +1 -0
- bibexpy/_web/_next/static/chunks/app/settings/page-002df809d6c5a227.js +1 -0
- bibexpy/_web/_next/static/chunks/app/tools/page-27eee1c6a590859f.js +1 -0
- bibexpy/_web/_next/static/chunks/fd9d1056-f2718be92181f426.js +1 -0
- bibexpy/_web/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
- bibexpy/_web/_next/static/chunks/main-app-d138a6e2f8c847f6.js +1 -0
- bibexpy/_web/_next/static/chunks/main-c4b9ddbf514503ef.js +1 -0
- bibexpy/_web/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
- bibexpy/_web/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
- bibexpy/_web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- bibexpy/_web/_next/static/chunks/webpack-c81f7fd28659d64f.js +1 -0
- bibexpy/_web/_next/static/css/1c4638f3e39cf127.css +3 -0
- bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_buildManifest.js +1 -0
- bibexpy/_web/_next/static/ypx2IQAkZiSaFiKWnzZGB/_ssgManifest.js +1 -0
- bibexpy/_web/images/PROMPTS.md +83 -0
- bibexpy/_web/images/about-illustration.png +0 -0
- bibexpy/_web/images/authors/alperen-sahin.jpg +0 -0
- bibexpy/_web/images/authors/burak-can-kara.jpg +0 -0
- bibexpy/_web/images/authors/taskin-dirsehan.jpg +0 -0
- bibexpy/_web/images/bibexpy-logo-full.png +0 -0
- bibexpy/_web/images/bibexpy-logo-header.png +0 -0
- bibexpy/_web/images/bibexpy-logo-in.png +0 -0
- bibexpy/_web/images/bibexpy-logo.webp +0 -0
- bibexpy/_web/images/hero-illustration.png +0 -0
- bibexpy/_web/images/loader.gif +0 -0
- bibexpy/_web/images/workflow-diagram.png +0 -0
- bibexpy/_web/index.html +1 -0
- bibexpy/_web/index.txt +11 -0
- bibexpy/_web/projects/_/convert/index.html +1 -0
- bibexpy/_web/projects/_/convert/index.txt +12 -0
- bibexpy/_web/projects/_/disambiguate/index.html +1 -0
- bibexpy/_web/projects/_/disambiguate/index.txt +12 -0
- bibexpy/_web/projects/_/enrich/index.html +1 -0
- bibexpy/_web/projects/_/enrich/index.txt +12 -0
- bibexpy/_web/projects/_/export/index.html +1 -0
- bibexpy/_web/projects/_/export/index.txt +12 -0
- bibexpy/_web/projects/_/merge/index.html +1 -0
- bibexpy/_web/projects/_/merge/index.txt +12 -0
- bibexpy/_web/projects/_/records/index.html +1 -0
- bibexpy/_web/projects/_/records/index.txt +12 -0
- bibexpy/_web/projects/_/report/index.html +1 -0
- bibexpy/_web/projects/_/report/index.txt +12 -0
- bibexpy/_web/projects/_/upload/index.html +1 -0
- bibexpy/_web/projects/_/upload/index.txt +12 -0
- bibexpy/_web/projects/index.html +1 -0
- bibexpy/_web/projects/index.txt +11 -0
- bibexpy/_web/settings/index.html +1 -0
- bibexpy/_web/settings/index.txt +11 -0
- bibexpy/_web/tools/README.md +65 -0
- bibexpy/_web/tools/bibexcel.svg +6 -0
- bibexpy/_web/tools/bibliometrix.png +0 -0
- bibexpy/_web/tools/biblioshiny.svg +12 -0
- bibexpy/_web/tools/citavi.svg +6 -0
- bibexpy/_web/tools/citespace.svg +7 -0
- bibexpy/_web/tools/citnetexplorer.svg +8 -0
- bibexpy/_web/tools/endnote.svg +6 -0
- bibexpy/_web/tools/excel.svg +1 -0
- bibexpy/_web/tools/gephi.svg +66 -0
- bibexpy/_web/tools/histcite.svg +7 -0
- bibexpy/_web/tools/index.html +1 -0
- bibexpy/_web/tools/index.txt +11 -0
- bibexpy/_web/tools/jabref.svg +122 -0
- bibexpy/_web/tools/latex.svg +1 -0
- bibexpy/_web/tools/mendeley.svg +1 -0
- bibexpy/_web/tools/openrefine.svg +15 -0
- bibexpy/_web/tools/overleaf.svg +1 -0
- bibexpy/_web/tools/papers.svg +7 -0
- bibexpy/_web/tools/powerbi.svg +1 -0
- bibexpy/_web/tools/python.svg +1 -0
- bibexpy/_web/tools/r.svg +1 -0
- bibexpy/_web/tools/refworks.svg +5 -0
- bibexpy/_web/tools/scite.svg +5 -0
- bibexpy/_web/tools/tableau.svg +1 -0
- bibexpy/_web/tools/vosviewer.png +0 -0
- bibexpy/_web/tools/zotero.svg +1 -0
- bibexpy/cli.py +242 -0
- bibexpy-2.0.0.dist-info/METADATA +105 -0
- bibexpy-2.0.0.dist-info/RECORD +171 -0
- bibexpy-2.0.0.dist-info/WHEEL +5 -0
- bibexpy-2.0.0.dist-info/entry_points.txt +2 -0
- bibexpy-2.0.0.dist-info/licenses/LICENSE +674 -0
- bibexpy-2.0.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,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
|