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,92 @@
1
+ import os
2
+ from pathlib import Path
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+
6
+ def _resolve_env_file() -> str:
7
+ """`.env` dosyasının yolu.
8
+
9
+ `BIBEXPY_CONFIG_DIR` env var'ı set ise oradaki `.env` kullanılır (paket
10
+ modu: ~/.bibexpy/.env gibi yazılabilir bir konum). Aksi halde CWD'deki
11
+ `.env` (geliştirme: uvicorn apps/api'den çalışır, CWD = apps/api).
12
+ """
13
+ cfg_dir = os.environ.get("BIBEXPY_CONFIG_DIR")
14
+ if cfg_dir:
15
+ return str(Path(cfg_dir).expanduser() / ".env")
16
+ return ".env"
17
+
18
+
19
+ def _default_storage_dir() -> str:
20
+ """Yazilabilir varsayilan depolama yolu.
21
+
22
+ `STORAGE_DIR` env'i (cli.py pakette set eder) varsa pydantic onu okur.
23
+ Aksi halde: gelistirme agacindaki mevcut `./storage` varsa onu (dev'i
24
+ bozmamak icin); yoksa kullanici-yazilabilir `~/.bibexpy/storage`
25
+ (pip/rastgele CWD'de veriyi rastgele yere yazmamak icin).
26
+ """
27
+ if Path("./storage").is_dir():
28
+ return "./storage"
29
+ return str(Path.home() / ".bibexpy" / "storage")
30
+
31
+
32
+ class Settings(BaseSettings):
33
+ model_config = SettingsConfigDict(
34
+ env_file=_resolve_env_file(),
35
+ env_file_encoding="utf-8",
36
+ extra="ignore",
37
+ )
38
+
39
+ api_host: str = "127.0.0.1"
40
+ api_port: int = 8001
41
+ api_cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001"
42
+
43
+ storage_dir: str = _default_storage_dir()
44
+
45
+ scopus_api_key: str = ""
46
+ semantic_scholar_api_key: str = ""
47
+ unpaywall_email: str = ""
48
+ crossref_email: str = ""
49
+
50
+ # LLM yapılandırması — yeni birleşik alanlar
51
+ llm_provider: str = "deepseek" # "deepseek" | "openai" | "custom"
52
+ llm_api_key: str = ""
53
+ llm_base_url: str = "https://api.deepseek.com"
54
+ llm_model: str = "deepseek-v4-flash"
55
+
56
+ # Geriye dönük uyumluluk — eski .env'lerden okuyacak
57
+ deepseek_api_key: str = ""
58
+ deepseek_model: str = ""
59
+ deepseek_base_url: str = ""
60
+
61
+ disambiguation_enabled: bool = True
62
+ disambiguation_blocking_threshold: float = 0.75
63
+ disambiguation_auto_approve_threshold: float = 0.95
64
+ # ORCID-öncelikli disambiguation: ambigu gruplar için OpenAlex'e anlık ORCID
65
+ # sorgusu (veride OI yoksa) ve çekileni veriye geri yazma.
66
+ disambiguation_orcid_lookup: bool = True
67
+ disambiguation_orcid_persist: bool = True
68
+
69
+ @property
70
+ def effective_llm_api_key(self) -> str:
71
+ return self.llm_api_key or self.deepseek_api_key
72
+
73
+ @property
74
+ def effective_llm_base_url(self) -> str:
75
+ return self.llm_base_url or self.deepseek_base_url or "https://api.deepseek.com"
76
+
77
+ @property
78
+ def effective_llm_model(self) -> str:
79
+ return self.llm_model or self.deepseek_model or "deepseek-v4-flash"
80
+
81
+ @property
82
+ def storage_path(self) -> Path:
83
+ p = Path(self.storage_dir).resolve()
84
+ p.mkdir(parents=True, exist_ok=True)
85
+ return p
86
+
87
+ @property
88
+ def cors_origins_list(self) -> list[str]:
89
+ return [o.strip() for o in self.api_cors_origins.split(",") if o.strip()]
90
+
91
+
92
+ settings = Settings()
@@ -0,0 +1,3 @@
1
+ from .runner import JobRunner, Job, JobStatus, job_runner
2
+
3
+ __all__ = ["JobRunner", "Job", "JobStatus", "job_runner"]
@@ -0,0 +1,301 @@
1
+ """Basit, tek-kullanıcılı job runner.
2
+
3
+ - Tek aktif iş + sıralı kuyruk (self-hosted için yeterli)
4
+ - Bellekte log + progress; opsiyonel olarak proje klasörüne persist
5
+ - SSE üzerinden canlı stream
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import functools
12
+ import time
13
+ import traceback
14
+ import uuid
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+ from typing import Any, Awaitable, Callable, Optional
19
+
20
+
21
+ # Ağır CPU işi (disambiguation blocking builder'ları) için AYRILMIŞ, sınırlı havuz.
22
+ # Neden: bu işler `asyncio.to_thread` ile çalışırsa PAYLAŞILAN default thread havuzunu
23
+ # kullanır. Bir scan iptal edilince `task.cancel()` yalnız asyncio task'ı iptal eder —
24
+ # `build_author_blocks` gibi fonksiyonu çalıştıran thread Python'da ÖLDÜRÜLEMEZ. İptal
25
+ # edilen scan'lerin orphan thread'leri default havuzu tüketir → export/merge/conversion
26
+ # ve SPA-serving dâhil TÜM `to_thread` işleri kuyruğa girer = uygulama kilitlenir
27
+ # (test bulgusu #7). Ağır işi ayrı havuza alınca orphan'lar burada izole kalır; default
28
+ # havuz (uygulamanın geri kalanı) yanıt vermeye devam eder. blocking.py'deki kooperatif
29
+ # iptal (should_cancel) sayesinde orphan'lar zaten ~bir iterasyonda biter.
30
+ _CPU_EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="bibex-cpu")
31
+
32
+
33
+ async def run_cpu(fn: Callable, *args, **kwargs):
34
+ """Ağır/iptal-edilebilir CPU işini AYRILMIŞ havuzda çalıştır (default havuzu koru).
35
+
36
+ `asyncio.to_thread` yerine bunu kullan: orphan thread'ler izole havuzda kalır,
37
+ uygulamanın geri kalanını (export/merge/serving) kilitlemez."""
38
+ loop = asyncio.get_running_loop()
39
+ return await loop.run_in_executor(_CPU_EXECUTOR, functools.partial(fn, *args, **kwargs))
40
+
41
+
42
+ class JobStatus(str, Enum):
43
+ queued = "queued"
44
+ running = "running"
45
+ completed = "completed"
46
+ failed = "failed"
47
+ cancelled = "cancelled"
48
+
49
+
50
+ @dataclass
51
+ class Job:
52
+ id: str
53
+ project_id: str
54
+ kind: str
55
+ title: str
56
+ # i18n: title_key + title_params → frontend (JobProgress) display-time çevirir.
57
+ # title alanı okunabilir fallback (markdown/legacy) olarak kalır.
58
+ title_key: Optional[str] = None
59
+ title_params: Optional[dict[str, Any]] = None
60
+ # exclusive: aynı projede aynı anda başka bir exclusive iş varsa REDDEDİLİR
61
+ # (ağır scan/fill işlerinin üst üste binip thread havuzunu tüketmesini önler — #7).
62
+ exclusive: bool = False
63
+ status: JobStatus = JobStatus.queued
64
+ progress: float = 0.0
65
+ log: list[str] = field(default_factory=list)
66
+ result: Any = None
67
+ error: Optional[str] = None
68
+ started_at: Optional[float] = None
69
+ finished_at: Optional[float] = None
70
+ created_at: float = field(default_factory=time.time)
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ return {
74
+ "id": self.id,
75
+ "project_id": self.project_id,
76
+ "kind": self.kind,
77
+ "title": self.title,
78
+ "title_key": self.title_key,
79
+ "title_params": self.title_params or None,
80
+ "status": self.status.value,
81
+ "progress": round(self.progress, 3),
82
+ "log_tail": self.log[-50:],
83
+ "result": self.result,
84
+ "error": self.error,
85
+ "started_at": self.started_at,
86
+ "finished_at": self.finished_at,
87
+ "created_at": self.created_at,
88
+ }
89
+
90
+
91
+ class JobContext:
92
+ """Worker fonksiyonuna geçirilen kontrol nesnesi."""
93
+
94
+ def __init__(self, job: Job, runner: "JobRunner"):
95
+ self._job = job
96
+ self._runner = runner
97
+
98
+ def log(self, line: str) -> None:
99
+ self._job.log.append(line)
100
+ self._runner._notify(self._job.id)
101
+
102
+ def progress(self, value: float) -> None:
103
+ self._job.progress = max(0.0, min(1.0, value))
104
+ self._runner._notify(self._job.id)
105
+
106
+ @property
107
+ def cancelled(self) -> bool:
108
+ return self._job.status == JobStatus.cancelled
109
+
110
+
111
+ JobWorker = Callable[[JobContext], Awaitable[Any]]
112
+
113
+
114
+ class JobRunner:
115
+ """Bellekte job kayıt + asyncio task'lar."""
116
+
117
+ def __init__(self):
118
+ self._jobs: dict[str, Job] = {}
119
+ self._tasks: dict[str, asyncio.Task] = {}
120
+ self._listeners: dict[str, set[asyncio.Queue]] = {}
121
+
122
+ # --- CRUD ---
123
+ def list(self, project_id: Optional[str] = None) -> list[Job]:
124
+ items = list(self._jobs.values())
125
+ if project_id:
126
+ items = [j for j in items if j.project_id == project_id]
127
+ items.sort(key=lambda j: j.created_at, reverse=True)
128
+ return items
129
+
130
+ def get(self, job_id: str) -> Optional[Job]:
131
+ return self._jobs.get(job_id)
132
+
133
+ def cancel(self, job_id: str) -> bool:
134
+ job = self._jobs.get(job_id)
135
+ if not job or job.status not in (JobStatus.queued, JobStatus.running):
136
+ return False
137
+ job.status = JobStatus.cancelled
138
+ task = self._tasks.get(job_id)
139
+ if task and not task.done():
140
+ task.cancel()
141
+ self._notify(job_id, done=True)
142
+ return True
143
+
144
+ def has_active_exclusive(self, project_id: str) -> bool:
145
+ """Bu projede aktif (kuyrukta/çalışan) bir exclusive iş var mı?"""
146
+ return any(
147
+ j.project_id == project_id and j.exclusive
148
+ and j.status in (JobStatus.queued, JobStatus.running)
149
+ for j in self._jobs.values()
150
+ )
151
+
152
+ # --- enqueue ---
153
+ def submit(
154
+ self,
155
+ project_id: str,
156
+ kind: str,
157
+ title: str,
158
+ worker: JobWorker,
159
+ *,
160
+ title_key: Optional[str] = None,
161
+ title_params: Optional[dict[str, Any]] = None,
162
+ exclusive: bool = False,
163
+ ) -> Job:
164
+ # Tek-aktif-iş guard (#7): exclusive bir iş istenirken aynı projede zaten
165
+ # exclusive bir iş varsa reddet. Frontend buton kilidi desenkronize olsa bile
166
+ # (eski donma bug'ının tetikleyicisi) ağır işler üst üste binmez.
167
+ if exclusive and self.has_active_exclusive(project_id):
168
+ from fastapi import HTTPException
169
+ raise HTTPException(409, "job_already_running")
170
+ job = Job(
171
+ id=uuid.uuid4().hex[:12], project_id=project_id, kind=kind,
172
+ title=title, title_key=title_key, title_params=title_params,
173
+ exclusive=exclusive,
174
+ )
175
+ self._jobs[job.id] = job
176
+ task = asyncio.create_task(self._run(job, worker))
177
+ self._tasks[job.id] = task
178
+ return job
179
+
180
+ async def _run(self, job: Job, worker: JobWorker) -> None:
181
+ ctx = JobContext(job, self)
182
+ try:
183
+ job.status = JobStatus.running
184
+ job.started_at = time.time()
185
+ self._notify(job.id)
186
+ result = await worker(ctx)
187
+ if job.status == JobStatus.cancelled:
188
+ return
189
+ job.result = result
190
+ job.progress = 1.0
191
+ job.status = JobStatus.completed
192
+ except asyncio.CancelledError:
193
+ job.status = JobStatus.cancelled
194
+ raise
195
+ except Exception as e:
196
+ job.status = JobStatus.failed
197
+ job.error = str(e)
198
+ job.log.append("HATA: " + str(e))
199
+ job.log.append(traceback.format_exc())
200
+ finally:
201
+ job.finished_at = time.time()
202
+ self._notify(job.id, done=True)
203
+ # Otomatik audit log yazımı — her job sonunda
204
+ self._write_audit(job)
205
+
206
+ def _write_audit(self, job: Job) -> None:
207
+ """Job tamamlandıktan sonra audit log'a yaz."""
208
+ try:
209
+ from services import audit # circular import'ı önle
210
+ duration = (job.finished_at - job.started_at) if (job.started_at and job.finished_at) else None
211
+ details: dict = {
212
+ "job_id": job.id,
213
+ "duration_seconds": round(duration, 2) if duration else None,
214
+ "status": job.status.value,
215
+ }
216
+ if isinstance(job.result, dict):
217
+ # Result alanlarının ilgili kısımlarını da ekle (verbose olmasın)
218
+ for k in ("method", "scopus_input", "wos_input", "merged_count", "duplicates_removed",
219
+ "stats", "enriched_count", "updated_count", "replacements", "snapshot",
220
+ "candidates", "clusters", "uncertain", "output_xlsx",
221
+ "clusters_proposed", "splits_proposed", "auto",
222
+ "orcid_rows_filled", "dois_fetched", # ORCID disambiguation write-back
223
+ # Field/selected enrichment
224
+ "field", "label", "total", "missing_before", "no_doi", "target",
225
+ "enriched", "api_no_data", "failed", "missing_after",
226
+ "fill_rate_before", "fill_rate_after", "per_field_fill",
227
+ "api", "doi", # fill_all alt-özetleri (enrichment raporu için)
228
+ "api_success_by_source", "fields_updated", "doi_missing",
229
+ # Smart Merge alanları
230
+ "matched_pairs", "borderline_count", "borderline_pending",
231
+ "conflict_count", "match_stages", "field_source_distribution",
232
+ "lost_wos_count", "lost_scopus_count", "output_files",
233
+ # Analiz klasörü
234
+ "analysis_id"):
235
+ if k in job.result:
236
+ v = job.result[k]
237
+ if isinstance(v, list):
238
+ details[k] = f"{len(v)} öğe" if len(v) > 8 else v
239
+ elif isinstance(v, float):
240
+ details[k] = round(v, 4)
241
+ else:
242
+ details[k] = v
243
+ if job.error:
244
+ details["error"] = job.error[:300]
245
+ audit.write(
246
+ job.project_id,
247
+ kind=job.kind,
248
+ title=f"{job.title} — {self._status_label(job.status)}",
249
+ title_key=job.title_key,
250
+ title_params=job.title_params,
251
+ details=details,
252
+ user_action=f"job_{job.status.value}",
253
+ )
254
+ except Exception:
255
+ pass # audit yazımı kritik değil — ana akışı bozmasın
256
+
257
+ @staticmethod
258
+ def _status_label(s: JobStatus) -> str:
259
+ return {
260
+ JobStatus.completed: "tamamlandı",
261
+ JobStatus.failed: "başarısız",
262
+ JobStatus.cancelled: "iptal edildi",
263
+ JobStatus.running: "çalışıyor",
264
+ JobStatus.queued: "kuyrukta",
265
+ }.get(s, str(s))
266
+
267
+ # --- SSE listeners ---
268
+ def subscribe(self, job_id: str) -> asyncio.Queue:
269
+ q: asyncio.Queue = asyncio.Queue(maxsize=500)
270
+ self._listeners.setdefault(job_id, set()).add(q)
271
+ # İlk frame: mevcut state
272
+ job = self._jobs.get(job_id)
273
+ if job:
274
+ q.put_nowait(job.to_dict())
275
+ return q
276
+
277
+ def unsubscribe(self, job_id: str, q: asyncio.Queue) -> None:
278
+ listeners = self._listeners.get(job_id)
279
+ if listeners:
280
+ listeners.discard(q)
281
+
282
+ def _notify(self, job_id: str, done: bool = False) -> None:
283
+ job = self._jobs.get(job_id)
284
+ if not job:
285
+ return
286
+ payload = job.to_dict()
287
+ for q in list(self._listeners.get(job_id, ())):
288
+ try:
289
+ q.put_nowait(payload)
290
+ except asyncio.QueueFull:
291
+ pass
292
+ if done:
293
+ # done sentinel
294
+ for q in list(self._listeners.get(job_id, ())):
295
+ try:
296
+ q.put_nowait(None)
297
+ except asyncio.QueueFull:
298
+ pass
299
+
300
+
301
+ job_runner = JobRunner()
@@ -0,0 +1,211 @@
1
+ """BibexPy v2 — FastAPI uygulama girişi.
2
+
3
+ Geliştirme:
4
+ cd apps/api && python -m uvicorn main:app --reload --port 8001
5
+ cd apps/web && npm run dev # ayrı terminal
6
+
7
+ Backend tüm router'ları `/api/` prefix altında mount eder; bu, ileride
8
+ frontend ile aynı host'tan serve edilse bile URL çakışmasını önler.
9
+
10
+ `_frontend_root()` — opsiyonel statik UI mount (varsa). Geliştirmede
11
+ genelde frontend ayrı çalıştığı için `None` döner ve devre dışı kalır.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ from fastapi import FastAPI, Request
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from fastapi.responses import FileResponse, JSONResponse
23
+ from fastapi.staticfiles import StaticFiles
24
+
25
+ from config import settings
26
+ from routers import (
27
+ projects, upload, convert, merge, jobs, downloads,
28
+ filter as filter_router, export as export_router, enrich,
29
+ disambiguate, prepare, export_folder, audit as audit_router,
30
+ quality as quality_router, records as records_router,
31
+ settings as settings_router, system as system_router,
32
+ tools as tools_router, report as report_router,
33
+ )
34
+
35
+
36
+ def _frontend_root() -> Optional[Path]:
37
+ """Next.js static export klasörünü bul. Yoksa None — API-only mode.
38
+
39
+ Geliştirmede genelde frontend ayrı (npm run dev, port 3000) çalışır;
40
+ bu fonksiyon None döner, backend sadece API serve eder.
41
+ """
42
+ # 1. Açık override (env var)
43
+ env = os.environ.get("BIBEXPY_FRONTEND_DIST")
44
+ if env:
45
+ p = Path(env)
46
+ if (p / "index.html").is_file():
47
+ return p
48
+
49
+ # 2. apps/web/out/ — eğer biri `npm run build:static` yaptıysa
50
+ here = Path(__file__).resolve().parent
51
+ p = here.parent / "web" / "out"
52
+ if (p / "index.html").is_file():
53
+ return p
54
+
55
+ return None
56
+
57
+
58
+ def _spa_fallback_response(path: str, root: Path) -> Optional[FileResponse]:
59
+ """Verilen URL path'i için doğru statik HTML dosyasını bul.
60
+
61
+ Dinamik project ID (`projects/<id>/...`) varsa placeholder `_` ile
62
+ eşleştirmeyi dener. Sonra root index.html'e düşer.
63
+ """
64
+ # 1. Tam dosya
65
+ full = (root / path).resolve()
66
+ # Path traversal guard
67
+ try:
68
+ full.relative_to(root)
69
+ except ValueError:
70
+ return None
71
+
72
+ if full.is_file():
73
+ return FileResponse(full)
74
+
75
+ # 2. Dizin + index.html
76
+ idx = full / "index.html"
77
+ if idx.is_file():
78
+ return FileResponse(idx)
79
+
80
+ # 3. Dinamik [id] segment: `projects/abc123/upload` → `projects/_/upload/index.html`
81
+ parts = path.strip("/").split("/")
82
+ if len(parts) >= 2 and parts[0] == "projects":
83
+ # parts[1] proje ID — `_` ile değiştir
84
+ ph_path = root.joinpath("projects", "_", *parts[2:]) / "index.html"
85
+ if ph_path.is_file():
86
+ return FileResponse(ph_path)
87
+
88
+ # 4. SPA son fallback — root index.html (404.html da denenebilir ama gerek yok)
89
+ fallback = root / "index.html"
90
+ if fallback.is_file():
91
+ return FileResponse(fallback)
92
+ return None
93
+
94
+
95
+ def create_app() -> FastAPI:
96
+ app = FastAPI(
97
+ title="BibexPy v2 API",
98
+ version="0.1.0",
99
+ description="Self-hosted bibliometric data preparation tool",
100
+ )
101
+
102
+ # CORS — dev modunda 3000'den 8001'e istek atan frontend için.
103
+ # Packaged binary'de tek port olduğu için CORS gereksiz ama zarar vermez.
104
+ app.add_middleware(
105
+ CORSMiddleware,
106
+ allow_origins=settings.cors_origins_list,
107
+ # Yerel araç: localhost / 127.0.0.1 hangi portta olursa olsun kabul et.
108
+ # (Açık liste + regex birlikte değerlendirilir.) Port değişse bile çalışır.
109
+ allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?",
110
+ allow_credentials=True,
111
+ allow_methods=["*"],
112
+ allow_headers=["*"],
113
+ )
114
+
115
+ # API router'ları — `/api/` prefix altında. Bu, frontend route'larıyla
116
+ # (örn. `/projects` UI sayfası vs `/api/projects` API endpoint'i) URL
117
+ # çakışmasını önler. Hem dev hem packaged binary için aynı prefix.
118
+ for r in (projects, upload, convert, merge, jobs, downloads, filter_router,
119
+ export_router, enrich, disambiguate, prepare, export_folder,
120
+ audit_router, quality_router, records_router, settings_router,
121
+ system_router, tools_router, report_router):
122
+ app.include_router(r.router, prefix="/api")
123
+
124
+ # Startup: soft-deleted analiz klasörlerini fiziksel temizle.
125
+ @app.on_event("startup")
126
+ def _purge_soft_deleted_on_startup() -> None:
127
+ try:
128
+ from services import storage as _storage, analyses as _analyses
129
+ total = 0
130
+ for proj in _storage.list_projects():
131
+ pid = getattr(proj, "id", None) or (
132
+ proj.get("id") if hasattr(proj, "get") else None
133
+ )
134
+ if not pid:
135
+ continue
136
+ try:
137
+ total += _analyses.purge_soft_deleted(pid)
138
+ except Exception:
139
+ pass
140
+ if total > 0:
141
+ import logging
142
+ logging.getLogger("uvicorn").info(
143
+ f"Purged {total} soft-deleted analysis folder(s)"
144
+ )
145
+ except Exception:
146
+ pass
147
+
148
+ # Meta endpoint'leri — frontend mount'tan önce, `/api/` altında
149
+ @app.get("/api/health", tags=["meta"])
150
+ def health() -> dict:
151
+ return {
152
+ "status": "ok",
153
+ "storage": str(settings.storage_path),
154
+ "disambiguation_enabled": settings.disambiguation_enabled,
155
+ "frontend_bundled": _frontend_root() is not None,
156
+ }
157
+
158
+ # Frontend mount — paketlenmiş veya dev'de export edilmiş ise
159
+ frontend_root = _frontend_root()
160
+ if frontend_root is not None:
161
+ # _next/static/* statik asset'leri — direkt mount, en hızlı yol
162
+ next_static = frontend_root / "_next"
163
+ if next_static.is_dir():
164
+ app.mount("/_next", StaticFiles(directory=next_static), name="_next")
165
+ # public/* asset'leri (görseller, vs.)
166
+ for sub in ("images", "tools", "favicon.ico"):
167
+ sub_path = frontend_root / sub
168
+ if sub_path.is_dir():
169
+ app.mount(f"/{sub}", StaticFiles(directory=sub_path), name=sub)
170
+
171
+ # Catch-all GET — SPA fallback (API ve mount'lardan SONRA register edilir,
172
+ # sadece yukarıdaki hiçbir route ile eşleşmeyen GET isteklerini yakalar)
173
+ @app.get("/{path:path}", include_in_schema=False)
174
+ async def spa_catchall(path: str, request: Request) -> FileResponse:
175
+ resp = _spa_fallback_response(path, frontend_root)
176
+ if resp is not None:
177
+ return resp
178
+ return JSONResponse({"detail": "Not Found"}, status_code=404)
179
+
180
+ # Root da SPA için
181
+ @app.get("/", include_in_schema=False)
182
+ async def spa_root() -> FileResponse:
183
+ return FileResponse(frontend_root / "index.html")
184
+ else:
185
+ # API-only mode (dev veya frontend build edilmemiş)
186
+ @app.get("/", tags=["meta"])
187
+ def root() -> dict:
188
+ return {
189
+ "name": "BibexPy v2 API",
190
+ "version": "0.1.0",
191
+ "description": "Self-hosted bibliometric data preparation tool",
192
+ "docs": "/docs",
193
+ "redoc": "/redoc",
194
+ "health": "/health",
195
+ "frontend_hint": "Frontend bundled değil. Geliştirme için: cd apps/web && npm run dev → http://localhost:3000",
196
+ }
197
+
198
+ return app
199
+
200
+
201
+ app = create_app()
202
+
203
+
204
+ if __name__ == "__main__":
205
+ import uvicorn
206
+ uvicorn.run(
207
+ "main:app",
208
+ host=settings.api_host,
209
+ port=settings.api_port,
210
+ reload=True,
211
+ )
File without changes
@@ -0,0 +1,25 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class ProjectCreate(BaseModel):
7
+ name: str = Field(min_length=1, max_length=120)
8
+ description: Optional[str] = None
9
+
10
+
11
+ class ProjectMeta(BaseModel):
12
+ id: str
13
+ name: str
14
+ description: Optional[str] = None
15
+ created_at: datetime
16
+ updated_at: datetime
17
+ file_count: int = 0
18
+ raw_size_bytes: int = 0
19
+
20
+
21
+ class UploadedFile(BaseModel):
22
+ name: str
23
+ size: int
24
+ kind: str # "scopus_csv" | "wos_txt" | "xlsx" | "unknown"
25
+ saved_path: str
File without changes