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