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,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