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,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,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()
|
bibexpy/_server/main.py
ADDED
|
@@ -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
|