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,131 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from services import audit, filter_engine, storage
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/projects/{project_id}/filter", tags=["filter"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FilterSpec(BaseModel):
|
|
12
|
+
year: Optional[dict[str, Any]] = None
|
|
13
|
+
citation_count: Optional[dict[str, Any]] = None
|
|
14
|
+
doc_type: Optional[list[str]] = None
|
|
15
|
+
language: Optional[list[str]] = None
|
|
16
|
+
db_source: Optional[list[str]] = None
|
|
17
|
+
journal: Optional[list[str]] = None
|
|
18
|
+
authors: Optional[list[str]] = None
|
|
19
|
+
wc_categories: Optional[list[str]] = None
|
|
20
|
+
sc_categories: Optional[list[str]] = None
|
|
21
|
+
fulltext: Optional[dict[str, Any]] = None
|
|
22
|
+
quality: Optional[dict[str, Any]] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FilterRequest(BaseModel):
|
|
26
|
+
spec: FilterSpec = Field(default_factory=FilterSpec)
|
|
27
|
+
offset: int = 0
|
|
28
|
+
limit: int = 50
|
|
29
|
+
columns: Optional[list[str]] = None
|
|
30
|
+
include_facets: bool = True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.post("")
|
|
34
|
+
def filter_records(project_id: str, payload: FilterRequest):
|
|
35
|
+
if storage.get_project(project_id) is None:
|
|
36
|
+
raise HTTPException(404, "project_not_found")
|
|
37
|
+
try:
|
|
38
|
+
df = filter_engine.load_merged(project_id)
|
|
39
|
+
except FileNotFoundError as e:
|
|
40
|
+
raise HTTPException(409, str(e))
|
|
41
|
+
|
|
42
|
+
spec_dict = {k: v for k, v in payload.spec.model_dump().items() if v is not None}
|
|
43
|
+
filtered = filter_engine.apply_filter(df, spec_dict)
|
|
44
|
+
|
|
45
|
+
result = filter_engine.paginate(
|
|
46
|
+
filtered,
|
|
47
|
+
offset=max(0, payload.offset),
|
|
48
|
+
limit=max(1, min(500, payload.limit)),
|
|
49
|
+
columns=payload.columns,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if payload.include_facets:
|
|
53
|
+
result["facets"] = filter_engine.compute_facets(filtered)
|
|
54
|
+
result["facets_all"] = filter_engine.compute_facets(df)
|
|
55
|
+
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- Preset CRUD ---
|
|
60
|
+
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
import json
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _presets_path(project_id: str) -> Path:
|
|
66
|
+
return storage.project_dir(project_id) / "filter_presets.json"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load_presets(project_id: str) -> list[dict]:
|
|
70
|
+
p = _presets_path(project_id)
|
|
71
|
+
if not p.exists():
|
|
72
|
+
return []
|
|
73
|
+
try:
|
|
74
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
75
|
+
except Exception:
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _save_presets(project_id: str, presets: list[dict]) -> None:
|
|
80
|
+
_presets_path(project_id).write_text(json.dumps(presets, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PresetCreate(BaseModel):
|
|
84
|
+
name: str = Field(min_length=1, max_length=80)
|
|
85
|
+
spec: FilterSpec
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.get("/presets")
|
|
89
|
+
def list_presets(project_id: str):
|
|
90
|
+
if storage.get_project(project_id) is None:
|
|
91
|
+
raise HTTPException(404, "project_not_found")
|
|
92
|
+
return _load_presets(project_id)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.post("/presets")
|
|
96
|
+
def create_preset(project_id: str, payload: PresetCreate):
|
|
97
|
+
if storage.get_project(project_id) is None:
|
|
98
|
+
raise HTTPException(404, "project_not_found")
|
|
99
|
+
presets = _load_presets(project_id)
|
|
100
|
+
presets = [p for p in presets if p.get("name") != payload.name] # overwrite
|
|
101
|
+
spec_dict = payload.spec.model_dump(exclude_none=True)
|
|
102
|
+
presets.append({"name": payload.name, "spec": spec_dict})
|
|
103
|
+
_save_presets(project_id, presets)
|
|
104
|
+
audit.write(
|
|
105
|
+
project_id,
|
|
106
|
+
kind="filter_save",
|
|
107
|
+
title=f"Preset kaydedildi: {payload.name}",
|
|
108
|
+
title_key="audit.titles.presetSaved",
|
|
109
|
+
title_params={"name": payload.name},
|
|
110
|
+
details={"name": payload.name, "filter_keys": list(spec_dict.keys()), "spec": spec_dict},
|
|
111
|
+
user_action="save_preset",
|
|
112
|
+
)
|
|
113
|
+
return {"ok": True, "count": len(presets)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@router.delete("/presets/{name}", status_code=204)
|
|
117
|
+
def delete_preset(project_id: str, name: str):
|
|
118
|
+
if storage.get_project(project_id) is None:
|
|
119
|
+
raise HTTPException(404, "project_not_found")
|
|
120
|
+
presets = [p for p in _load_presets(project_id) if p.get("name") != name]
|
|
121
|
+
_save_presets(project_id, presets)
|
|
122
|
+
audit.write(
|
|
123
|
+
project_id,
|
|
124
|
+
kind="filter_save",
|
|
125
|
+
title=f"Preset silindi: {name}",
|
|
126
|
+
title_key="audit.titles.presetDeleted",
|
|
127
|
+
title_params={"name": name},
|
|
128
|
+
details={"name": name, "action": "delete"},
|
|
129
|
+
user_action="delete_preset",
|
|
130
|
+
)
|
|
131
|
+
return None
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
|
+
from sse_starlette.sse import EventSourceResponse
|
|
7
|
+
|
|
8
|
+
from jobs.runner import job_runner
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("")
|
|
14
|
+
def list_jobs(project_id: Optional[str] = None):
|
|
15
|
+
return [j.to_dict() for j in job_runner.list(project_id)]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/{job_id}")
|
|
19
|
+
def get_job(job_id: str):
|
|
20
|
+
job = job_runner.get(job_id)
|
|
21
|
+
if not job:
|
|
22
|
+
raise HTTPException(404, "job_not_found")
|
|
23
|
+
return job.to_dict()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.post("/{job_id}/cancel")
|
|
27
|
+
def cancel_job(job_id: str):
|
|
28
|
+
if not job_runner.cancel(job_id):
|
|
29
|
+
raise HTTPException(404, "job_not_found_or_uncancelable")
|
|
30
|
+
return {"ok": True}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/{job_id}/stream")
|
|
34
|
+
async def stream_job(job_id: str):
|
|
35
|
+
job = job_runner.get(job_id)
|
|
36
|
+
if not job:
|
|
37
|
+
raise HTTPException(404, "job_not_found")
|
|
38
|
+
|
|
39
|
+
queue = job_runner.subscribe(job_id)
|
|
40
|
+
|
|
41
|
+
# Eğer job zaten bitmiş ise — son durumu hemen gönder ve kapat.
|
|
42
|
+
# Race condition: kullanıcı UI'da görür görmez subscribe oluyor, bu sırada job
|
|
43
|
+
# tamamlanmış olabilir. Bu durumda subscribe-ettikten sonra hiçbir event gelmez
|
|
44
|
+
# ve stream "yarım" görünür (ERR_INCOMPLETE_CHUNKED_ENCODING). Mevcut snapshot'u
|
|
45
|
+
# ilk event olarak emit edip ardından done emit edelim.
|
|
46
|
+
initial_snapshot = job.to_dict() if job else None
|
|
47
|
+
finished_states = {"completed", "failed", "cancelled"}
|
|
48
|
+
job_finished_already = (initial_snapshot or {}).get("status") in finished_states
|
|
49
|
+
|
|
50
|
+
async def event_gen():
|
|
51
|
+
try:
|
|
52
|
+
# İlk snapshot — UI'a anında güncel durumu göster
|
|
53
|
+
if initial_snapshot is not None:
|
|
54
|
+
yield {"event": "update", "data": json.dumps(initial_snapshot)}
|
|
55
|
+
if job_finished_already:
|
|
56
|
+
yield {"event": "done", "data": "1"}
|
|
57
|
+
return
|
|
58
|
+
while True:
|
|
59
|
+
msg = await queue.get()
|
|
60
|
+
if msg is None:
|
|
61
|
+
yield {"event": "done", "data": "1"}
|
|
62
|
+
return
|
|
63
|
+
yield {"event": "update", "data": json.dumps(msg)}
|
|
64
|
+
except asyncio.CancelledError:
|
|
65
|
+
return
|
|
66
|
+
finally:
|
|
67
|
+
job_runner.unsubscribe(job_id, queue)
|
|
68
|
+
|
|
69
|
+
# ping_message_factory ile her 15 saniyede bir keep-alive yorum satırı gönder
|
|
70
|
+
# — bu, bazı reverse-proxy / browser tarafında bağlantıyı yarım kesik (chunked
|
|
71
|
+
# encoding incomplete) gibi gösteren durumları azaltır.
|
|
72
|
+
return EventSourceResponse(event_gen(), ping=15)
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from fastapi import APIRouter, HTTPException
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from jobs.runner import job_runner
|
|
10
|
+
from services import analyses, merger, storage
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/projects/{project_id}/merge", tags=["merge"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MergeRequest(BaseModel):
|
|
16
|
+
# Tek algoritma: Smart Merge. Alan geriye uyumluluk için duruyor ama yok sayılır.
|
|
17
|
+
method: str = "smart"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.post("")
|
|
21
|
+
async def start_merge(project_id: str, payload: MergeRequest | None = None):
|
|
22
|
+
if storage.get_project(project_id) is None:
|
|
23
|
+
raise HTTPException(404, "project_not_found")
|
|
24
|
+
# Tek algoritma: Smart Merge — payload.method artık yok sayılır.
|
|
25
|
+
# Hazırlık kontrolü — job submit ETMEDEN önce; hazırlanmış XLSX yoksa 409 (UI
|
|
26
|
+
# kullanıcıyı 1. adıma yönlendirir). Böylece merge ham CSV/TXT'yi sessizce kullanmaz.
|
|
27
|
+
merger.check_ready_to_merge(project_id)
|
|
28
|
+
|
|
29
|
+
async def worker(ctx):
|
|
30
|
+
return await merger.run_merge(ctx, project_id)
|
|
31
|
+
|
|
32
|
+
job = job_runner.submit(
|
|
33
|
+
project_id=project_id,
|
|
34
|
+
kind="merge",
|
|
35
|
+
title="Birleştirme (Smart Merge)",
|
|
36
|
+
title_key="audit.titles.merge",
|
|
37
|
+
title_params={"method": "smart"},
|
|
38
|
+
worker=worker,
|
|
39
|
+
)
|
|
40
|
+
return {"job_id": job.id}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("/results")
|
|
44
|
+
def list_merged(project_id: str):
|
|
45
|
+
if storage.get_project(project_id) is None:
|
|
46
|
+
raise HTTPException(404, "project_not_found")
|
|
47
|
+
return merger.list_merged(project_id)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Alan kodları → açıklayıcı Türkçe etiket
|
|
51
|
+
FIELD_LABELS = {
|
|
52
|
+
"TI": "Başlık", "AU": "Yazarlar (kısa)", "AF": "Yazarlar (tam)",
|
|
53
|
+
"SO": "Dergi/Kaynak", "JI": "Dergi (ISO)", "J9": "Dergi (kısa)",
|
|
54
|
+
"PY": "Yıl", "TC": "Atıf sayısı", "DT": "Doküman tipi", "LA": "Dil",
|
|
55
|
+
"DI": "DOI", "AB": "Özet", "DE": "Anahtar Kelimeler", "ID": "Keywords Plus",
|
|
56
|
+
"C1": "Adresler", "RP": "İletişim yazarı", "EM": "E-posta",
|
|
57
|
+
"CR": "Referanslar", "NR": "Ref. sayısı", "PG": "Sayfa", "PU": "Yayıncı",
|
|
58
|
+
"WC": "WoS Kategorisi", "SC": "Konu Kategorisi", "DB": "Kaynak DB",
|
|
59
|
+
"UT": "WoS ID", "PM": "PubMed ID", "VL": "Cilt", "IS": "Sayı",
|
|
60
|
+
"BP": "Başlangıç sayfa", "EP": "Bitiş sayfa", "OA": "Açık Erişim",
|
|
61
|
+
"SR": "Source-Year ref",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _merge_is_stale(project_id: str, meta: dict[str, Any] | None) -> bool:
|
|
66
|
+
"""Ham (raw) veriler aktif merge'den SONRA eklendiyse/değiştiyse True.
|
|
67
|
+
|
|
68
|
+
Hazırlık artık merge içinde örtük yapıldığından, kullanıcı yeni ham dosya
|
|
69
|
+
eklediğinde raw/*.{csv,txt,isi} mtime'ı merge zamanını geçer → aktif merge
|
|
70
|
+
eski veriye dayanır. UI bunu "Eski Merge" olarak işaretleyip kullanıcıyı
|
|
71
|
+
yeniden Smart Merge'e yönlendirir.
|
|
72
|
+
"""
|
|
73
|
+
if not meta:
|
|
74
|
+
return False
|
|
75
|
+
merge_ts = meta.get("completed_at") or meta.get("created_at")
|
|
76
|
+
if not merge_ts:
|
|
77
|
+
return False
|
|
78
|
+
try:
|
|
79
|
+
raw = storage.project_dir(project_id) / "raw"
|
|
80
|
+
if not raw.exists():
|
|
81
|
+
return False
|
|
82
|
+
newest = 0.0
|
|
83
|
+
for pat in ("*.csv", "*.txt", "*.isi"):
|
|
84
|
+
for f in raw.glob(pat):
|
|
85
|
+
try:
|
|
86
|
+
newest = max(newest, f.stat().st_mtime)
|
|
87
|
+
except OSError:
|
|
88
|
+
continue
|
|
89
|
+
# 2 sn tolerans: merge'in completed_at'i ile son yüklemenin mtime'ı
|
|
90
|
+
# arasındaki küçük farktan ötürü yanlış-pozitif olmasın.
|
|
91
|
+
return bool(newest and newest > float(merge_ts) + 2.0)
|
|
92
|
+
except Exception:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@router.get("/summary")
|
|
97
|
+
def merge_summary(project_id: str) -> dict[str, Any]:
|
|
98
|
+
"""Aktif analizin detaylı istatistikleri.
|
|
99
|
+
|
|
100
|
+
has_merge = aktif bir analiz klasörünün varlığı. Statistic.xlsx olsa da
|
|
101
|
+
olmasa da has_merge=True döner; Statistic yoksa sadece `general` boş kalır,
|
|
102
|
+
`files` listesi yine doldurulur.
|
|
103
|
+
"""
|
|
104
|
+
if storage.get_project(project_id) is None:
|
|
105
|
+
raise HTTPException(404, "project_not_found")
|
|
106
|
+
|
|
107
|
+
# Aktif analiz klasörü
|
|
108
|
+
merged_dir = analyses.get_active_analysis_dir(project_id)
|
|
109
|
+
if merged_dir is None or not merged_dir.exists():
|
|
110
|
+
return {"has_merge": False}
|
|
111
|
+
|
|
112
|
+
out: dict[str, Any] = {"has_merge": True}
|
|
113
|
+
# Analiz meta'sını ek (Statistic'ten bağımsız — analiz varsa hep döner)
|
|
114
|
+
meta = analyses.get_analysis_meta(project_id, merged_dir.name)
|
|
115
|
+
if meta:
|
|
116
|
+
out["analysis"] = {
|
|
117
|
+
"id": meta.get("id"),
|
|
118
|
+
"label": meta.get("label"),
|
|
119
|
+
"method": meta.get("method"),
|
|
120
|
+
"created_at": meta.get("created_at"),
|
|
121
|
+
"completed_at": meta.get("completed_at"),
|
|
122
|
+
"is_active": meta.get("is_active"),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Bayatlık: işlenmiş veriler bu merge'den sonra yeniden hazırlandıysa
|
|
126
|
+
# (yeni dosya + tekrar prepare) aktif merge eski veriye dayanır.
|
|
127
|
+
out["stale"] = _merge_is_stale(project_id, meta)
|
|
128
|
+
|
|
129
|
+
# Statistic.xlsx tercih edilir; geriye uyumluluk için Statistic_Smart de denenir
|
|
130
|
+
stats_xlsx = merged_dir / "Statistic.xlsx"
|
|
131
|
+
if not stats_xlsx.exists():
|
|
132
|
+
stats_xlsx_smart = merged_dir / "Statistic_Smart.xlsx"
|
|
133
|
+
if stats_xlsx_smart.exists():
|
|
134
|
+
stats_xlsx = stats_xlsx_smart
|
|
135
|
+
|
|
136
|
+
# 1) Genel istatistikler — önce Statistic.xlsx'ten oku, yoksa merged.xlsx + lost'tan hesapla
|
|
137
|
+
if stats_xlsx.exists():
|
|
138
|
+
try:
|
|
139
|
+
gen = pd.read_excel(stats_xlsx, sheet_name="General Stats")
|
|
140
|
+
if len(gen) > 0:
|
|
141
|
+
row = gen.iloc[0]
|
|
142
|
+
out["general"] = {
|
|
143
|
+
"total_records": int(row.get("Total Records", 0)),
|
|
144
|
+
"wos_records": int(row.get("WoS Records", 0)),
|
|
145
|
+
"scopus_records": int(row.get("Scopus Records", 0)),
|
|
146
|
+
"merged_columns": int(row.get("Merged Columns", 0)),
|
|
147
|
+
"common_columns": int(row.get("Common Columns", 0)),
|
|
148
|
+
}
|
|
149
|
+
wos = out["general"]["wos_records"]
|
|
150
|
+
scopus = out["general"]["scopus_records"]
|
|
151
|
+
total_in = wos + scopus
|
|
152
|
+
merged = out["general"]["total_records"]
|
|
153
|
+
out["general"]["duplicates_removed"] = max(0, total_in - merged)
|
|
154
|
+
out["general"]["total_input"] = total_in
|
|
155
|
+
out["general"]["dedup_rate"] = round((total_in - merged) / total_in, 4) if total_in else 0
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
# Fallback: Statistic.xlsx yok / okunamadı → merged.xlsx'ten hesapla
|
|
160
|
+
if "general" not in out:
|
|
161
|
+
try:
|
|
162
|
+
from services import merger
|
|
163
|
+
dataset = merger.merged_dataset_path(project_id)
|
|
164
|
+
if dataset and dataset.exists():
|
|
165
|
+
df = pd.read_excel(dataset)
|
|
166
|
+
total_records = int(len(df))
|
|
167
|
+
merged_columns = int(len(df.columns))
|
|
168
|
+
out["general"] = {
|
|
169
|
+
"total_records": total_records,
|
|
170
|
+
"wos_records": 0,
|
|
171
|
+
"scopus_records": 0,
|
|
172
|
+
"merged_columns": merged_columns,
|
|
173
|
+
"common_columns": 0,
|
|
174
|
+
"duplicates_removed": 0,
|
|
175
|
+
"total_input": total_records,
|
|
176
|
+
"dedup_rate": 0,
|
|
177
|
+
}
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
# 2) Alan-bazlı istatistikler
|
|
182
|
+
if stats_xlsx.exists():
|
|
183
|
+
try:
|
|
184
|
+
fs = pd.read_excel(stats_xlsx, sheet_name="Field Stats")
|
|
185
|
+
field_col = fs.columns[0]
|
|
186
|
+
fields = []
|
|
187
|
+
total = out.get("general", {}).get("total_records", 0)
|
|
188
|
+
for _, r in fs.iterrows():
|
|
189
|
+
code = str(r[field_col]).strip()
|
|
190
|
+
if not code or code.lower() == "nan":
|
|
191
|
+
continue
|
|
192
|
+
missing = int(r.get("Missing Count", 0))
|
|
193
|
+
pct = float(r.get("Missing %", 0))
|
|
194
|
+
status = str(r.get("Status", ""))
|
|
195
|
+
fields.append({
|
|
196
|
+
"field": code,
|
|
197
|
+
"label": FIELD_LABELS.get(code, code),
|
|
198
|
+
"total": total,
|
|
199
|
+
"missing": missing,
|
|
200
|
+
"filled": total - missing,
|
|
201
|
+
"missing_pct": round(pct, 2),
|
|
202
|
+
"fill_rate": round((total - missing) / total, 4) if total else 0,
|
|
203
|
+
"status": status,
|
|
204
|
+
})
|
|
205
|
+
out["fields"] = fields
|
|
206
|
+
except Exception:
|
|
207
|
+
out["fields"] = []
|
|
208
|
+
else:
|
|
209
|
+
out["fields"] = []
|
|
210
|
+
|
|
211
|
+
# 3) Dosyalar — ana çıktı + yan dosyalar
|
|
212
|
+
files = []
|
|
213
|
+
lost_wos = 0
|
|
214
|
+
lost_scopus = 0
|
|
215
|
+
for f in sorted(merged_dir.iterdir()):
|
|
216
|
+
if not f.is_file() or f.name == "meta.json":
|
|
217
|
+
continue
|
|
218
|
+
kind = "other"
|
|
219
|
+
name_lower = f.name.lower()
|
|
220
|
+
if name_lower in ("merged.xlsx",) or ("merged" in name_lower and f.suffix.lower() == ".xlsx"):
|
|
221
|
+
kind = "merged_dataset"
|
|
222
|
+
elif name_lower.startswith("lost_wos"):
|
|
223
|
+
kind = "lost_wos"
|
|
224
|
+
try:
|
|
225
|
+
lost_wos = int(len(pd.read_excel(f)))
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
elif name_lower.startswith("lost_scopus"):
|
|
229
|
+
kind = "lost_scopus"
|
|
230
|
+
try:
|
|
231
|
+
lost_scopus = int(len(pd.read_excel(f)))
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
234
|
+
elif name_lower.startswith("statistic"):
|
|
235
|
+
kind = "statistics"
|
|
236
|
+
elif name_lower == "match_audit.xlsx":
|
|
237
|
+
kind = "match_audit"
|
|
238
|
+
elif name_lower == "conflict_log.xlsx":
|
|
239
|
+
kind = "conflict_log"
|
|
240
|
+
elif name_lower == "borderline_queue.xlsx":
|
|
241
|
+
kind = "borderline_queue"
|
|
242
|
+
files.append({
|
|
243
|
+
"name": f.name,
|
|
244
|
+
"size": f.stat().st_size,
|
|
245
|
+
"mtime": f.stat().st_mtime,
|
|
246
|
+
"kind": kind,
|
|
247
|
+
"relative_path": str(f.relative_to(storage.settings.storage_path)),
|
|
248
|
+
})
|
|
249
|
+
out["files"] = files
|
|
250
|
+
out["lost_wos_count"] = lost_wos
|
|
251
|
+
out["lost_scopus_count"] = lost_scopus
|
|
252
|
+
|
|
253
|
+
# 4) Method — tek algoritma Smart Merge. meta.json'da varsa onu kullan
|
|
254
|
+
# ("single" tek-kaynak için), yoksa smart varsay.
|
|
255
|
+
method = (meta.get("method") if meta else None) or "smart"
|
|
256
|
+
out["method"] = method
|
|
257
|
+
|
|
258
|
+
# 5) Smart-özel istatistikler
|
|
259
|
+
if method == "smart":
|
|
260
|
+
# match_audit.xlsx → stage dağılımı
|
|
261
|
+
audit_xlsx = merged_dir / "match_audit.xlsx"
|
|
262
|
+
if audit_xlsx.exists():
|
|
263
|
+
try:
|
|
264
|
+
audit_df = pd.read_excel(audit_xlsx)
|
|
265
|
+
if "stage_label" in audit_df.columns:
|
|
266
|
+
stage_counts = audit_df["stage_label"].value_counts().to_dict()
|
|
267
|
+
out["match_stages"] = {str(k): int(v) for k, v in stage_counts.items()}
|
|
268
|
+
except Exception:
|
|
269
|
+
out["match_stages"] = {}
|
|
270
|
+
|
|
271
|
+
# conflict_log.xlsx → çakışma sayısı
|
|
272
|
+
conflict_xlsx = merged_dir / "conflict_log.xlsx"
|
|
273
|
+
if conflict_xlsx.exists():
|
|
274
|
+
try:
|
|
275
|
+
cdf = pd.read_excel(conflict_xlsx)
|
|
276
|
+
out["conflict_count"] = int(len(cdf))
|
|
277
|
+
if "chosen_source" in cdf.columns:
|
|
278
|
+
src_dist = cdf["chosen_source"].value_counts().to_dict()
|
|
279
|
+
out["field_source_distribution"] = {str(k): int(v) for k, v in src_dist.items()}
|
|
280
|
+
except Exception:
|
|
281
|
+
out["conflict_count"] = 0
|
|
282
|
+
|
|
283
|
+
# borderline state'ten pending sayısı
|
|
284
|
+
state_path = merged_dir / "borderline_state.json"
|
|
285
|
+
if state_path.exists():
|
|
286
|
+
try:
|
|
287
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
288
|
+
out["borderline_pending"] = sum(1 for v in state.values() if v.get("status") == "pending")
|
|
289
|
+
out["borderline_total"] = len(state)
|
|
290
|
+
except Exception:
|
|
291
|
+
out["borderline_pending"] = 0
|
|
292
|
+
out["borderline_total"] = 0
|
|
293
|
+
else:
|
|
294
|
+
out["borderline_pending"] = 0
|
|
295
|
+
out["borderline_total"] = 0
|
|
296
|
+
|
|
297
|
+
return out
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ────────────────────────────────────────────────────────────────────
|
|
301
|
+
# Smart Merge — Borderline review endpoint'leri
|
|
302
|
+
# ────────────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
@router.get("/borderline")
|
|
305
|
+
def list_borderline(project_id: str):
|
|
306
|
+
"""Smart Merge'in borderline kuyruğunu listele (manuel onay için)."""
|
|
307
|
+
if storage.get_project(project_id) is None:
|
|
308
|
+
raise HTTPException(404, "project_not_found")
|
|
309
|
+
from services import smart_merger
|
|
310
|
+
return smart_merger.list_borderline(project_id)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class BorderlineDecision(BaseModel):
|
|
314
|
+
pair_id: str
|
|
315
|
+
decision: str # "accept" | "reject" | "skip"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class BorderlineDecidePayload(BaseModel):
|
|
319
|
+
decisions: list[BorderlineDecision] = Field(default_factory=list)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@router.post("/borderline/decide")
|
|
323
|
+
def decide_borderline(project_id: str, payload: BorderlineDecidePayload):
|
|
324
|
+
"""Kullanıcının borderline kararlarını uygula. Snapshot alır, audit yazar."""
|
|
325
|
+
if storage.get_project(project_id) is None:
|
|
326
|
+
raise HTTPException(404, "project_not_found")
|
|
327
|
+
from services import smart_merger
|
|
328
|
+
decisions = [d.model_dump() for d in payload.decisions]
|
|
329
|
+
if not decisions:
|
|
330
|
+
raise HTTPException(400, "decisions_empty")
|
|
331
|
+
valid = {"accept", "reject", "skip"}
|
|
332
|
+
for d in decisions:
|
|
333
|
+
if d["decision"] not in valid:
|
|
334
|
+
raise HTTPException(400, f"invalid_decision: {d['decision']}")
|
|
335
|
+
return smart_merger.decide_borderline(project_id, decisions)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ────────────────────────────────────────────────────────────────────
|
|
339
|
+
# Analiz yönetimi — her birleştirme bağımsız analiz klasörüdür
|
|
340
|
+
# ────────────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
@router.get("/analyses")
|
|
343
|
+
def list_analyses_endpoint(project_id: str):
|
|
344
|
+
"""Bu projedeki tüm analizleri en yeniden eskiye listele.
|
|
345
|
+
|
|
346
|
+
Her item: {id, label, method, created_at, completed_at?, file_count, total_size, is_active}
|
|
347
|
+
"""
|
|
348
|
+
if storage.get_project(project_id) is None:
|
|
349
|
+
raise HTTPException(404, "project_not_found")
|
|
350
|
+
return {
|
|
351
|
+
"active_id": analyses.get_active_analysis_id(project_id),
|
|
352
|
+
"items": analyses.list_analyses(project_id),
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@router.post("/analyses/{analysis_id}/activate")
|
|
357
|
+
def activate_analysis(project_id: str, analysis_id: str):
|
|
358
|
+
"""Bir analizi aktif yap — Records/Filter/Quality bundan sonra onun üzerinde çalışır."""
|
|
359
|
+
if storage.get_project(project_id) is None:
|
|
360
|
+
raise HTTPException(404, "project_not_found")
|
|
361
|
+
meta = analyses.get_analysis_meta(project_id, analysis_id)
|
|
362
|
+
if not meta:
|
|
363
|
+
raise HTTPException(404, "analysis_not_found")
|
|
364
|
+
analyses.set_active_analysis(project_id, analysis_id)
|
|
365
|
+
# Filter cache invalidate (aktif dataset değişti)
|
|
366
|
+
try:
|
|
367
|
+
from services import filter_engine
|
|
368
|
+
filter_engine._DF_CACHE.clear()
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
# Audit
|
|
372
|
+
try:
|
|
373
|
+
from services import audit
|
|
374
|
+
audit.write(
|
|
375
|
+
project_id,
|
|
376
|
+
kind="analysis_activate",
|
|
377
|
+
title=f"Aktif analiz: {meta.get('label') or analysis_id}",
|
|
378
|
+
title_key="audit.titles.analysisActivated",
|
|
379
|
+
title_params={"label": meta.get("label") or analysis_id},
|
|
380
|
+
details={
|
|
381
|
+
"analysis_id": analysis_id,
|
|
382
|
+
"method": meta.get("method"),
|
|
383
|
+
"label": meta.get("label"),
|
|
384
|
+
},
|
|
385
|
+
user_action="analysis_activate",
|
|
386
|
+
)
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
return {"ok": True, "active_id": analysis_id}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@router.delete("/analyses/{analysis_id}")
|
|
393
|
+
def delete_analysis_endpoint(project_id: str, analysis_id: str):
|
|
394
|
+
"""Bir analizi sil. Windows'ta dosya kilidi varsa soft-delete'e düşer
|
|
395
|
+
(UI'da görünmez ama dosyalar diskte). Aktifse aktiflik en yeni başka
|
|
396
|
+
analize geçer."""
|
|
397
|
+
if storage.get_project(project_id) is None:
|
|
398
|
+
raise HTTPException(404, "project_not_found")
|
|
399
|
+
meta = analyses.get_analysis_meta(project_id, analysis_id)
|
|
400
|
+
if not meta:
|
|
401
|
+
raise HTTPException(404, "analysis_not_found")
|
|
402
|
+
ok = analyses.delete_analysis(project_id, analysis_id)
|
|
403
|
+
if not ok:
|
|
404
|
+
raise HTTPException(500, "analysis_delete_failed")
|
|
405
|
+
# Cache temizle
|
|
406
|
+
try:
|
|
407
|
+
from services import filter_engine
|
|
408
|
+
filter_engine._DF_CACHE.clear()
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
# Audit
|
|
412
|
+
try:
|
|
413
|
+
from services import audit
|
|
414
|
+
audit.write(
|
|
415
|
+
project_id,
|
|
416
|
+
kind="analysis_delete",
|
|
417
|
+
title=f"Analiz silindi: {meta.get('label') or analysis_id}",
|
|
418
|
+
title_key="audit.titles.analysisDeleted",
|
|
419
|
+
title_params={"label": meta.get("label") or analysis_id},
|
|
420
|
+
details={
|
|
421
|
+
"analysis_id": analysis_id,
|
|
422
|
+
"method": meta.get("method"),
|
|
423
|
+
},
|
|
424
|
+
user_action="analysis_delete",
|
|
425
|
+
)
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
active_id = analyses.get_active_analysis_id(project_id)
|
|
429
|
+
# Bu analizin geçmiş kayıtlarını sil — her analizin AYRI geçmişi (Burak: "hepsinin
|
|
430
|
+
# geçmişi ayrı olmalı"). Proje-seviye kurulum (upload vb.) ve DİĞER analizlerin
|
|
431
|
+
# kayıtları korunur.
|
|
432
|
+
try:
|
|
433
|
+
from services import audit as _audit
|
|
434
|
+
_audit.delete_for_analysis(project_id, analysis_id)
|
|
435
|
+
except Exception:
|
|
436
|
+
pass
|
|
437
|
+
return {"ok": True, "active_id": active_id}
|