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,65 @@
|
|
|
1
|
+
"""Audit log endpoint'leri."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
|
+
from fastapi.responses import PlainTextResponse
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from services import analyses, audit, storage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/projects/{project_id}/audit", tags=["audit"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuditEntry(BaseModel):
|
|
17
|
+
kind: str
|
|
18
|
+
title: str
|
|
19
|
+
details: Optional[dict[str, Any]] = None
|
|
20
|
+
user_action: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("")
|
|
24
|
+
def list_entries(project_id: str, limit: int = 200):
|
|
25
|
+
if storage.get_project(project_id) is None:
|
|
26
|
+
raise HTTPException(404, "project_not_found")
|
|
27
|
+
# Aktif analizin geçmişi (+ proje-seviye kurulum) — her analizin AYRI geçmişi.
|
|
28
|
+
active = analyses.get_active_analysis_id(project_id)
|
|
29
|
+
return audit.read(project_id, limit=limit, analysis_id=active)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/summary")
|
|
33
|
+
def get_summary(project_id: str):
|
|
34
|
+
if storage.get_project(project_id) is None:
|
|
35
|
+
raise HTTPException(404, "project_not_found")
|
|
36
|
+
return audit.summary(project_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.post("")
|
|
40
|
+
def add_entry(project_id: str, payload: AuditEntry):
|
|
41
|
+
if storage.get_project(project_id) is None:
|
|
42
|
+
raise HTTPException(404, "project_not_found")
|
|
43
|
+
return audit.write(
|
|
44
|
+
project_id,
|
|
45
|
+
kind=payload.kind,
|
|
46
|
+
title=payload.title,
|
|
47
|
+
details=payload.details,
|
|
48
|
+
user_action=payload.user_action,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.delete("", status_code=204)
|
|
53
|
+
def clear_entries(project_id: str):
|
|
54
|
+
if storage.get_project(project_id) is None:
|
|
55
|
+
raise HTTPException(404, "project_not_found")
|
|
56
|
+
audit.clear(project_id)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.get("/report.md", response_class=PlainTextResponse)
|
|
61
|
+
def markdown_report(project_id: str):
|
|
62
|
+
if storage.get_project(project_id) is None:
|
|
63
|
+
raise HTTPException(404, "project_not_found")
|
|
64
|
+
active = analyses.get_active_analysis_id(project_id)
|
|
65
|
+
return audit.format_markdown_report(project_id, analysis_id=active)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException
|
|
4
|
+
from fastapi.responses import FileResponse
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from services import audit, converter, storage
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/projects/{project_id}/convert", tags=["convert"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConvertCsvRequest(BaseModel):
|
|
13
|
+
files: list[str]
|
|
14
|
+
output: str = "scopus_merged.xlsx"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConvertWosRequest(BaseModel):
|
|
18
|
+
files: list[str]
|
|
19
|
+
output: str = "wos_merged.xlsx"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConvertXlsxRequest(BaseModel):
|
|
23
|
+
file: str
|
|
24
|
+
output: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConvertResult(BaseModel):
|
|
28
|
+
name: str
|
|
29
|
+
size: int
|
|
30
|
+
relative_path: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _result(path: Path) -> ConvertResult:
|
|
34
|
+
return ConvertResult(
|
|
35
|
+
name=path.name,
|
|
36
|
+
size=path.stat().st_size,
|
|
37
|
+
relative_path=str(path.relative_to(storage.settings.storage_path)),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.get("/processed", response_model=list[ConvertResult])
|
|
42
|
+
def list_processed(project_id: str):
|
|
43
|
+
return converter.list_processed(project_id)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _audit_convert(project_id: str, kind: str, inputs: list[str] | str, output: Path) -> None:
|
|
47
|
+
audit.write(
|
|
48
|
+
project_id,
|
|
49
|
+
kind="convert",
|
|
50
|
+
title=f"{kind}: {output.name}",
|
|
51
|
+
title_key="audit.titles.converted",
|
|
52
|
+
title_params={"name": output.name},
|
|
53
|
+
analysis_id=None, # proje-seviye tool
|
|
54
|
+
details={
|
|
55
|
+
"type": kind,
|
|
56
|
+
"inputs": inputs if isinstance(inputs, list) else [inputs],
|
|
57
|
+
"output": output.name,
|
|
58
|
+
"output_size": output.stat().st_size,
|
|
59
|
+
},
|
|
60
|
+
user_action="convert",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.post("/csv-to-xlsx", response_model=ConvertResult)
|
|
65
|
+
def csv_to_xlsx(project_id: str, payload: ConvertCsvRequest):
|
|
66
|
+
p = converter.csv_to_xlsx(project_id, payload.files, payload.output)
|
|
67
|
+
_audit_convert(project_id, "Scopus CSV → XLSX", payload.files, p)
|
|
68
|
+
return _result(p)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.post("/wos-to-xlsx", response_model=ConvertResult)
|
|
72
|
+
def wos_to_xlsx(project_id: str, payload: ConvertWosRequest):
|
|
73
|
+
p = converter.wos_to_xlsx(project_id, payload.files, payload.output)
|
|
74
|
+
_audit_convert(project_id, "WoS TXT → XLSX", payload.files, p)
|
|
75
|
+
return _result(p)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.post("/xlsx-to-wos-txt", response_model=ConvertResult)
|
|
79
|
+
def xlsx_to_wos_txt(project_id: str, payload: ConvertXlsxRequest):
|
|
80
|
+
p = converter.xlsx_to_wos_txt(project_id, payload.file, payload.output)
|
|
81
|
+
_audit_convert(project_id, "XLSX → WoS TXT", payload.file, p)
|
|
82
|
+
return _result(p)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.post("/xlsx-to-tsv", response_model=ConvertResult)
|
|
86
|
+
def xlsx_to_tsv(project_id: str, payload: ConvertXlsxRequest):
|
|
87
|
+
p = converter.xlsx_to_tsv(project_id, payload.file, payload.output)
|
|
88
|
+
_audit_convert(project_id, "XLSX → TSV", payload.file, p)
|
|
89
|
+
return _result(p)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.get("/download/{filename}")
|
|
93
|
+
def download(project_id: str, filename: str):
|
|
94
|
+
meta = storage.get_project(project_id)
|
|
95
|
+
if meta is None:
|
|
96
|
+
raise HTTPException(404, "project_not_found")
|
|
97
|
+
name = Path(filename).name
|
|
98
|
+
target = storage.project_dir(project_id) / "processed" / name
|
|
99
|
+
if not target.exists() or not target.is_file():
|
|
100
|
+
raise HTTPException(404, "file_not_found")
|
|
101
|
+
return FileResponse(target, filename=name)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from config import settings
|
|
7
|
+
from jobs.runner import job_runner
|
|
8
|
+
from services import audit, storage
|
|
9
|
+
from services.disambiguation import pipeline
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/projects/{project_id}/disambiguate", tags=["disambiguate"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("/status")
|
|
15
|
+
def status(project_id: str):
|
|
16
|
+
if storage.get_project(project_id) is None:
|
|
17
|
+
raise HTTPException(404, "project_not_found")
|
|
18
|
+
return {
|
|
19
|
+
"enabled": settings.disambiguation_enabled,
|
|
20
|
+
"configured": bool(settings.effective_llm_api_key),
|
|
21
|
+
"model": settings.effective_llm_model,
|
|
22
|
+
"provider": settings.llm_provider or "deepseek",
|
|
23
|
+
"base_url": settings.effective_llm_base_url,
|
|
24
|
+
"blocking_threshold": settings.disambiguation_blocking_threshold,
|
|
25
|
+
"auto_approve_threshold": settings.disambiguation_auto_approve_threshold,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DisambiguateRequest(BaseModel):
|
|
30
|
+
# `mode` geriye uyumluluk için durur; artık davranışı etkilemez (3-katman varsayılan).
|
|
31
|
+
mode: str = "auto"
|
|
32
|
+
# Opsiyonel maliyet/test sınırı — sadece ilk N kayıt taranır. None = tüm dataset.
|
|
33
|
+
max_records: int | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.post("/authors")
|
|
37
|
+
async def start_authors(project_id: str, payload: DisambiguateRequest | None = None):
|
|
38
|
+
if storage.get_project(project_id) is None:
|
|
39
|
+
raise HTTPException(404, "project_not_found")
|
|
40
|
+
if not settings.disambiguation_enabled:
|
|
41
|
+
raise HTTPException(400, "disambiguation_disabled_hint")
|
|
42
|
+
max_records = payload.max_records if payload else None
|
|
43
|
+
|
|
44
|
+
async def worker(ctx):
|
|
45
|
+
return await pipeline.run_author_disambiguation(ctx, project_id, max_records=max_records)
|
|
46
|
+
|
|
47
|
+
job = job_runner.submit(
|
|
48
|
+
project_id, "disambiguate_authors", "Yazar Ayrıştırma",
|
|
49
|
+
worker, title_key="audit.titles.authorDisambiguation", exclusive=True,
|
|
50
|
+
)
|
|
51
|
+
return {"job_id": job.id, "max_records": max_records}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.post("/affiliations")
|
|
55
|
+
async def start_affiliations(project_id: str, payload: DisambiguateRequest | None = None):
|
|
56
|
+
if storage.get_project(project_id) is None:
|
|
57
|
+
raise HTTPException(404, "project_not_found")
|
|
58
|
+
if not settings.disambiguation_enabled:
|
|
59
|
+
raise HTTPException(400, "disambiguation_disabled")
|
|
60
|
+
max_records = payload.max_records if payload else None
|
|
61
|
+
|
|
62
|
+
async def worker(ctx):
|
|
63
|
+
return await pipeline.run_affiliation_disambiguation(ctx, project_id, max_records=max_records)
|
|
64
|
+
|
|
65
|
+
job = job_runner.submit(
|
|
66
|
+
project_id, "disambiguate_affiliations", "Kurum Normalizasyonu",
|
|
67
|
+
worker, title_key="audit.titles.affiliationStandardization", exclusive=True,
|
|
68
|
+
)
|
|
69
|
+
return {"job_id": job.id}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/countries")
|
|
73
|
+
async def start_countries(project_id: str, payload: DisambiguateRequest | None = None):
|
|
74
|
+
if storage.get_project(project_id) is None:
|
|
75
|
+
raise HTTPException(404, "project_not_found")
|
|
76
|
+
if not settings.disambiguation_enabled:
|
|
77
|
+
raise HTTPException(400, "disambiguation_disabled")
|
|
78
|
+
max_records = payload.max_records if payload else None
|
|
79
|
+
|
|
80
|
+
async def worker(ctx):
|
|
81
|
+
return await pipeline.run_country_standardization(ctx, project_id, max_records=max_records)
|
|
82
|
+
|
|
83
|
+
job = job_runner.submit(
|
|
84
|
+
project_id, "disambiguate", "Ülke Standartlaştırma",
|
|
85
|
+
worker, title_key="audit.titles.countryStandardization", exclusive=True,
|
|
86
|
+
)
|
|
87
|
+
return {"job_id": job.id}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.post("/organizations")
|
|
91
|
+
async def start_organizations(project_id: str, payload: DisambiguateRequest | None = None):
|
|
92
|
+
if storage.get_project(project_id) is None:
|
|
93
|
+
raise HTTPException(404, "project_not_found")
|
|
94
|
+
if not settings.disambiguation_enabled:
|
|
95
|
+
raise HTTPException(400, "disambiguation_disabled")
|
|
96
|
+
max_records = payload.max_records if payload else None
|
|
97
|
+
|
|
98
|
+
async def worker(ctx):
|
|
99
|
+
return await pipeline.run_org_rollup(ctx, project_id, max_records=max_records)
|
|
100
|
+
|
|
101
|
+
job = job_runner.submit(
|
|
102
|
+
project_id, "disambiguate", "Kurum Toplama (üst kurum)",
|
|
103
|
+
worker, title_key="audit.titles.orgRollup", exclusive=True,
|
|
104
|
+
)
|
|
105
|
+
return {"job_id": job.id}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@router.get("/proposals/{kind}")
|
|
109
|
+
def get_proposals(project_id: str, kind: str):
|
|
110
|
+
if storage.get_project(project_id) is None:
|
|
111
|
+
raise HTTPException(404, "project_not_found")
|
|
112
|
+
if kind not in ("authors", "affiliations", "countries", "organizations"):
|
|
113
|
+
raise HTTPException(400, "invalid_kind")
|
|
114
|
+
return pipeline.list_proposals(project_id, kind)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ApplyRequest(BaseModel):
|
|
118
|
+
kind: str
|
|
119
|
+
approved: list[dict[str, Any]] = [] # birleştirme (merge) onayları
|
|
120
|
+
approved_splits: list[dict[str, Any]] = [] # ayrıştırma (split) onayları — yalnız authors
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.post("/apply")
|
|
124
|
+
def apply(project_id: str, payload: ApplyRequest):
|
|
125
|
+
if storage.get_project(project_id) is None:
|
|
126
|
+
raise HTTPException(404, "project_not_found")
|
|
127
|
+
if payload.kind not in ("authors", "affiliations", "countries", "organizations"):
|
|
128
|
+
raise HTTPException(400, "invalid_kind")
|
|
129
|
+
|
|
130
|
+
# Önce AYRIŞTIRMA (split), sonra BİRLEŞTİRME (merge) — sıra: ayrıştırma ilk işlem.
|
|
131
|
+
split_result = None
|
|
132
|
+
if payload.kind == "authors" and payload.approved_splits:
|
|
133
|
+
split_result = pipeline.apply_splits(project_id, payload.approved_splits)
|
|
134
|
+
merge_result = None
|
|
135
|
+
if payload.approved:
|
|
136
|
+
merge_result = pipeline.apply_clusters(project_id, payload.kind, payload.approved)
|
|
137
|
+
|
|
138
|
+
replacements = (
|
|
139
|
+
(split_result or {}).get("replacements", 0) + (merge_result or {}).get("replacements", 0)
|
|
140
|
+
)
|
|
141
|
+
snapshot = (merge_result or {}).get("snapshot") or (split_result or {}).get("snapshot")
|
|
142
|
+
audit.write(
|
|
143
|
+
project_id,
|
|
144
|
+
kind="disambiguate",
|
|
145
|
+
title=f"Yazar Ayrıştırma uygulandı ({payload.kind}) — {replacements} değişiklik",
|
|
146
|
+
title_key="audit.titles.disambiguationApplied",
|
|
147
|
+
title_params={"kind": payload.kind, "n": replacements},
|
|
148
|
+
details={
|
|
149
|
+
"kind": payload.kind,
|
|
150
|
+
"clusters_approved": len(payload.approved),
|
|
151
|
+
"splits_approved": len(payload.approved_splits),
|
|
152
|
+
"replacements": replacements,
|
|
153
|
+
},
|
|
154
|
+
snapshot=snapshot,
|
|
155
|
+
user_action="apply_disambiguation",
|
|
156
|
+
)
|
|
157
|
+
return {"merge": merge_result, "split": split_result, "replacements": replacements, "snapshot": snapshot}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class RestoreRequest(BaseModel):
|
|
161
|
+
snapshot: str
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@router.post("/restore")
|
|
165
|
+
def restore(project_id: str, payload: RestoreRequest):
|
|
166
|
+
if storage.get_project(project_id) is None:
|
|
167
|
+
raise HTTPException(404, "project_not_found")
|
|
168
|
+
result = pipeline.restore_snapshot(project_id, payload.snapshot)
|
|
169
|
+
audit.write(
|
|
170
|
+
project_id,
|
|
171
|
+
kind="snapshot_restore",
|
|
172
|
+
title=f"Disambiguation snapshot geri yüklendi",
|
|
173
|
+
title_key="audit.titles.disambiguationRestored",
|
|
174
|
+
details={"snapshot": payload.snapshot, "into": result.get("into")},
|
|
175
|
+
user_action="restore",
|
|
176
|
+
)
|
|
177
|
+
return result
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Üretilen dosyalar için genel indirme endpoint'i."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
|
+
from fastapi.responses import FileResponse
|
|
7
|
+
|
|
8
|
+
from services import storage
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/projects/{project_id}/download", tags=["downloads"])
|
|
11
|
+
|
|
12
|
+
# İndirilebilir alt klasörler. analyses/ direkt değil, alt klasörleri ile gelir (frontend gerekirse path bildirir).
|
|
13
|
+
ALLOWED = {"raw", "processed", "merged", "exports", "snapshots"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/{folder}/{filename}")
|
|
17
|
+
def download(project_id: str, folder: str, filename: str):
|
|
18
|
+
if storage.get_project(project_id) is None:
|
|
19
|
+
raise HTTPException(404, "project_not_found")
|
|
20
|
+
if folder not in ALLOWED:
|
|
21
|
+
raise HTTPException(400, f"invalid_folder: {folder}")
|
|
22
|
+
name = Path(filename).name
|
|
23
|
+
target = storage.project_dir(project_id) / folder / name
|
|
24
|
+
if not target.exists() or not target.is_file():
|
|
25
|
+
raise HTTPException(404, "file_not_found")
|
|
26
|
+
return FileResponse(target, filename=name)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from jobs.runner import job_runner
|
|
5
|
+
from services import enricher, storage
|
|
6
|
+
|
|
7
|
+
router = APIRouter(prefix="/projects/{project_id}/enrich", tags=["enrich"])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApiEnrichRequest(BaseModel):
|
|
11
|
+
sources: list[str] | None = None # ileride seçimli kullanmak için
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.post("/fill-all")
|
|
15
|
+
async def start_fill_all(project_id: str):
|
|
16
|
+
"""Birleşik doldurma: DOI başına tek API çağrısı, tüm boş alanlar (yalnız API; ML yoktur)."""
|
|
17
|
+
if storage.get_project(project_id) is None:
|
|
18
|
+
raise HTTPException(404, "project_not_found")
|
|
19
|
+
|
|
20
|
+
async def worker(ctx):
|
|
21
|
+
return await enricher.run_fill_all(ctx, project_id)
|
|
22
|
+
|
|
23
|
+
job = job_runner.submit(
|
|
24
|
+
project_id, "enrich_api", "Eksik alanları doldur (API)", worker,
|
|
25
|
+
title_key="audit.titles.fillAll", exclusive=True,
|
|
26
|
+
)
|
|
27
|
+
return {"job_id": job.id}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.post("/api")
|
|
31
|
+
async def start_api_enrichment(project_id: str, payload: ApiEnrichRequest):
|
|
32
|
+
if storage.get_project(project_id) is None:
|
|
33
|
+
raise HTTPException(404, "project_not_found")
|
|
34
|
+
|
|
35
|
+
async def worker(ctx):
|
|
36
|
+
return await enricher.run_api_enrichment(ctx, project_id, payload.sources)
|
|
37
|
+
|
|
38
|
+
job = job_runner.submit(
|
|
39
|
+
project_id, "enrich_api", "API ile zenginleştirme", worker,
|
|
40
|
+
title_key="audit.titles.apiEnrichment", exclusive=True,
|
|
41
|
+
)
|
|
42
|
+
return {"job_id": job.id}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, HTTPException
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from services import audit, exporter, storage
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/projects/{project_id}/export", tags=["export"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExportRequest(BaseModel):
|
|
13
|
+
fmt: str
|
|
14
|
+
filter: Optional[dict[str, Any]] = None
|
|
15
|
+
output_name: Optional[str] = None
|
|
16
|
+
# "rename" (default) → yeni timestamp'li dosya, eskiler kalır
|
|
17
|
+
# "replace" → aynı format'taki tüm eski dosyalar silinir, yenisi yazılır
|
|
18
|
+
# "abort" → aynı format zaten varsa hata fırlat (409)
|
|
19
|
+
if_exists: str = "rename"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ExportResult(BaseModel):
|
|
23
|
+
name: str
|
|
24
|
+
size: int
|
|
25
|
+
relative_path: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _list_format_files(project_id: str, fmt: str) -> list[Path]:
|
|
29
|
+
"""Aynı format'taki mevcut export dosyalarını döner."""
|
|
30
|
+
root = storage.project_dir(project_id)
|
|
31
|
+
exports_dir = root / "exports"
|
|
32
|
+
if not exports_dir.exists():
|
|
33
|
+
return []
|
|
34
|
+
suffix = f".{fmt.lower()}"
|
|
35
|
+
return [f for f in exports_dir.iterdir() if f.is_file() and f.suffix.lower() == suffix]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("", response_model=list[ExportResult])
|
|
39
|
+
def list_exports(project_id: str):
|
|
40
|
+
if storage.get_project(project_id) is None:
|
|
41
|
+
raise HTTPException(404, "project_not_found")
|
|
42
|
+
return exporter.list_exports(project_id)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.post("", response_model=ExportResult)
|
|
46
|
+
def create_export(project_id: str, payload: ExportRequest):
|
|
47
|
+
if storage.get_project(project_id) is None:
|
|
48
|
+
raise HTTPException(404, "project_not_found")
|
|
49
|
+
|
|
50
|
+
# Aynı format için mevcut dosyaları if_exists'e göre ele al
|
|
51
|
+
if_exists = (payload.if_exists or "rename").lower()
|
|
52
|
+
if if_exists not in ("rename", "replace", "abort"):
|
|
53
|
+
raise HTTPException(400, "invalid_if_exists")
|
|
54
|
+
|
|
55
|
+
existing = _list_format_files(project_id, payload.fmt)
|
|
56
|
+
replaced_count = 0
|
|
57
|
+
if existing:
|
|
58
|
+
if if_exists == "abort":
|
|
59
|
+
raise HTTPException(409, f"format_files_exist: {len(existing)}")
|
|
60
|
+
if if_exists == "replace":
|
|
61
|
+
for f in existing:
|
|
62
|
+
try:
|
|
63
|
+
f.unlink()
|
|
64
|
+
replaced_count += 1
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
p = exporter.export(project_id, payload.fmt, payload.filter, payload.output_name)
|
|
69
|
+
filtered = bool(payload.filter)
|
|
70
|
+
audit.write(
|
|
71
|
+
project_id,
|
|
72
|
+
kind="export",
|
|
73
|
+
title=f"Export ({payload.fmt.upper()}): {p.name}",
|
|
74
|
+
title_key="audit.titles.exported",
|
|
75
|
+
title_params={"fmt": payload.fmt.upper(), "name": p.name},
|
|
76
|
+
details={
|
|
77
|
+
"format": payload.fmt,
|
|
78
|
+
"output": p.name,
|
|
79
|
+
"output_size": p.stat().st_size,
|
|
80
|
+
"filtered": filtered,
|
|
81
|
+
"filter_keys": list((payload.filter or {}).keys()) if filtered else [],
|
|
82
|
+
"if_exists": if_exists,
|
|
83
|
+
"replaced_count": replaced_count,
|
|
84
|
+
},
|
|
85
|
+
user_action="export",
|
|
86
|
+
)
|
|
87
|
+
return ExportResult(name=p.name, size=p.stat().st_size,
|
|
88
|
+
relative_path=str(p.relative_to(storage.settings.storage_path)))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.delete("/{name}")
|
|
92
|
+
def delete_export(project_id: str, name: str):
|
|
93
|
+
"""Belirli bir export dosyasını sil."""
|
|
94
|
+
if storage.get_project(project_id) is None:
|
|
95
|
+
raise HTTPException(404, "project_not_found")
|
|
96
|
+
# Path-traversal koruması
|
|
97
|
+
safe_name = Path(name).name
|
|
98
|
+
if not safe_name or safe_name != name:
|
|
99
|
+
raise HTTPException(400, "invalid_filename")
|
|
100
|
+
target = storage.project_dir(project_id) / "exports" / safe_name
|
|
101
|
+
if not target.exists() or not target.is_file():
|
|
102
|
+
raise HTTPException(404, "file_not_found")
|
|
103
|
+
try:
|
|
104
|
+
target.unlink()
|
|
105
|
+
except Exception as e:
|
|
106
|
+
raise HTTPException(500, f"delete_failed: {e}")
|
|
107
|
+
audit.write(
|
|
108
|
+
project_id,
|
|
109
|
+
kind="export",
|
|
110
|
+
title=f"Export silindi: {safe_name}",
|
|
111
|
+
title_key="audit.titles.exportDeleted",
|
|
112
|
+
title_params={"name": safe_name},
|
|
113
|
+
details={"deleted": safe_name},
|
|
114
|
+
user_action="export_delete",
|
|
115
|
+
)
|
|
116
|
+
return {"ok": True, "deleted": safe_name}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Belirli bir lokal klasöre kopyalama — self-hosted için.
|
|
2
|
+
|
|
3
|
+
Self-hosted tek kullanıcı senaryosunda backend ve kullanıcı aynı makinededir;
|
|
4
|
+
backend'in yazma yetkisi olan herhangi bir klasöre çıktıyı bırakabilir.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, HTTPException
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from services import audit, storage
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix="/projects/{project_id}/export-folder", tags=["export-folder"])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CopyRequest(BaseModel):
|
|
20
|
+
files: list[str] # exports/ altındaki dosya adları
|
|
21
|
+
target_folder: str # mutlak path (örn. C:\Users\me\Desktop\bibex_out)
|
|
22
|
+
create_if_missing: bool = True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CopyReport(BaseModel):
|
|
26
|
+
target_folder: str
|
|
27
|
+
copied: list[str]
|
|
28
|
+
skipped: list[dict[str, Any]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.post("", response_model=CopyReport)
|
|
32
|
+
def copy_to_folder(project_id: str, payload: CopyRequest):
|
|
33
|
+
if storage.get_project(project_id) is None:
|
|
34
|
+
raise HTTPException(404, "project_not_found")
|
|
35
|
+
|
|
36
|
+
target = Path(payload.target_folder).expanduser()
|
|
37
|
+
if not target.is_absolute():
|
|
38
|
+
raise HTTPException(400, "target_folder_not_absolute")
|
|
39
|
+
|
|
40
|
+
if not target.exists():
|
|
41
|
+
if payload.create_if_missing:
|
|
42
|
+
try:
|
|
43
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
raise HTTPException(400, f"folder_create_failed: {e}")
|
|
46
|
+
else:
|
|
47
|
+
raise HTTPException(400, "target_folder_missing")
|
|
48
|
+
|
|
49
|
+
exports = storage.project_dir(project_id) / "exports"
|
|
50
|
+
copied: list[str] = []
|
|
51
|
+
skipped: list[dict[str, Any]] = []
|
|
52
|
+
|
|
53
|
+
for name in payload.files:
|
|
54
|
+
name = Path(name).name # safety
|
|
55
|
+
src = exports / name
|
|
56
|
+
if not src.exists():
|
|
57
|
+
skipped.append({"name": name, "reason": "Kaynak dosya bulunamadı"})
|
|
58
|
+
continue
|
|
59
|
+
dst = target / name
|
|
60
|
+
try:
|
|
61
|
+
shutil.copy2(src, dst)
|
|
62
|
+
copied.append(str(dst))
|
|
63
|
+
except Exception as e:
|
|
64
|
+
skipped.append({"name": name, "reason": str(e)})
|
|
65
|
+
|
|
66
|
+
audit.write(
|
|
67
|
+
project_id,
|
|
68
|
+
kind="export",
|
|
69
|
+
title=f"{len(copied)} dosya lokal klasöre kopyalandı",
|
|
70
|
+
title_key="audit.titles.filesCopiedToFolder",
|
|
71
|
+
title_params={"n": len(copied)},
|
|
72
|
+
details={
|
|
73
|
+
"target_folder": str(target),
|
|
74
|
+
"copied_count": len(copied),
|
|
75
|
+
"skipped_count": len(skipped),
|
|
76
|
+
"copied": copied,
|
|
77
|
+
"skipped": skipped,
|
|
78
|
+
},
|
|
79
|
+
user_action="copy_to_folder",
|
|
80
|
+
)
|
|
81
|
+
return CopyReport(target_folder=str(target), copied=copied, skipped=skipped)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@router.get("/suggest", response_model=dict)
|
|
85
|
+
def suggest_folders():
|
|
86
|
+
"""Önerilen çıktı klasörleri — kullanıcının ev dizini altında."""
|
|
87
|
+
home = Path.home()
|
|
88
|
+
out = {
|
|
89
|
+
"home": str(home),
|
|
90
|
+
"suggestions": [
|
|
91
|
+
{"label": "İndirilenler", "path": str(home / "Downloads")},
|
|
92
|
+
{"label": "Belgeler", "path": str(home / "Documents")},
|
|
93
|
+
{"label": "Masaüstü", "path": str(home / "Desktop")},
|
|
94
|
+
{"label": "BibexPy çıktı", "path": str(home / "Documents" / "BibexPy")},
|
|
95
|
+
],
|
|
96
|
+
}
|
|
97
|
+
return out
|