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,985 @@
|
|
|
1
|
+
"""Disambiguation pipeline orchestration — blocking -> LLM -> sonuç."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from fastapi import HTTPException
|
|
13
|
+
|
|
14
|
+
from config import settings
|
|
15
|
+
from jobs.runner import JobContext, run_cpu
|
|
16
|
+
from services import analyses, merger, storage
|
|
17
|
+
from .blocking import (
|
|
18
|
+
build_author_blocks, build_affiliation_blocks, build_author_splits,
|
|
19
|
+
build_country_blocks, build_org_rollup,
|
|
20
|
+
)
|
|
21
|
+
from .cache import DisambiguationCache
|
|
22
|
+
from . import orcid as orcid_mod
|
|
23
|
+
from .deepseek_client import (
|
|
24
|
+
DeepSeekClient,
|
|
25
|
+
AUTHOR_SYSTEM_PROMPT,
|
|
26
|
+
AUTHOR_SPLIT_SYSTEM_PROMPT,
|
|
27
|
+
AFFILIATION_SYSTEM_PROMPT,
|
|
28
|
+
COUNTRY_SYSTEM_PROMPT,
|
|
29
|
+
ORG_ROLLUP_SYSTEM_PROMPT,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _project_dir(project_id: str) -> Path:
|
|
34
|
+
"""Disambiguation çalışma klasörü = AKTİF ANALİZ klasörü (proje kökü değil).
|
|
35
|
+
|
|
36
|
+
Böylece öneriler (disambiguation_*.json), snapshot'lar ve audit'ler ait oldukları
|
|
37
|
+
analizin içinde tutulur; yeni bir merge (yeni analiz) eski önerileri TAŞIMAZ —
|
|
38
|
+
otomatik temiz başlangıç. Hiç analiz yoksa proje köküne düşer (geriye uyumlu).
|
|
39
|
+
"""
|
|
40
|
+
if storage.get_project(project_id) is None:
|
|
41
|
+
raise HTTPException(404, "project_not_found")
|
|
42
|
+
return analyses.work_dir(project_id)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_merged(project_id: str) -> pd.DataFrame:
|
|
46
|
+
p = merger.merged_dataset_path(project_id)
|
|
47
|
+
if p is None:
|
|
48
|
+
raise HTTPException(409, "no_merged_data")
|
|
49
|
+
return pd.read_excel(p)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _distinct_variants(cluster: dict) -> set[str]:
|
|
53
|
+
"""Bir kümenin birleştirdiği farklı yazım/varyant kümesi.
|
|
54
|
+
|
|
55
|
+
Üye objesi `name_variants` (yazar) veya `variants` (affiliation/org/ülke)
|
|
56
|
+
taşır; hiçbiri yoksa üyenin kendi `id`'si tek varyant sayılır. <2 varyant =
|
|
57
|
+
birleştirilecek bir şey yok (tek-üyeli LLM kümesi) → öneri olarak gösterilmez.
|
|
58
|
+
"""
|
|
59
|
+
out: set[str] = set()
|
|
60
|
+
for m in cluster.get("members", []):
|
|
61
|
+
vs = m.get("name_variants") or m.get("variants") or ([m["id"]] if m.get("id") else [])
|
|
62
|
+
out.update(v for v in vs if v)
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _label_for_llm(members: list[dict], variant_key: str,
|
|
67
|
+
context_keys: tuple = ()) -> tuple[list[dict], dict]:
|
|
68
|
+
"""Her VARYANTI tek tek v1..vN ile etiketleyip LLM'e gonder.
|
|
69
|
+
|
|
70
|
+
Neden varyant duzeyi (aday duzeyi degil): aday duzeyinde etiketlemede,
|
|
71
|
+
cok-varyantli tek bir aday ('v1') icindeki varyantlari LLM 'v1_10' gibi
|
|
72
|
+
ALT-INDEKSLIYOR; bu etiketler aday id'siyle eslesmiyor ve kart 'v1_10'
|
|
73
|
+
gibi gorunuyordu. Her varyanta kendi 'vN' etiketini verince LLM'in
|
|
74
|
+
alt-indekslemesine gerek kalmaz; donen yanit dogrudan haritalanir.
|
|
75
|
+
|
|
76
|
+
Her oge: {id:'vN', <name|text>: varyant, **baglam}. Donen:
|
|
77
|
+
(LLM ogeleri, {etiket: ham varyant dizesi}).
|
|
78
|
+
"""
|
|
79
|
+
text_field = "name" if variant_key == "name_variants" else "text"
|
|
80
|
+
items: list[dict] = []
|
|
81
|
+
by_label: dict[str, str] = {}
|
|
82
|
+
n = 0
|
|
83
|
+
for m in members:
|
|
84
|
+
vs = m.get(variant_key) or ([m["id"]] if m.get("id") else [])
|
|
85
|
+
ctx = {k: m[k] for k in context_keys if m.get(k)}
|
|
86
|
+
for v in vs:
|
|
87
|
+
if not v:
|
|
88
|
+
continue
|
|
89
|
+
n += 1
|
|
90
|
+
label = f"v{n}"
|
|
91
|
+
items.append({"id": label, text_field: v, **ctx})
|
|
92
|
+
by_label[label] = v
|
|
93
|
+
return items, by_label
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _members_from_labels(cluster: dict, by_label: dict, variant_key: str) -> list[dict]:
|
|
97
|
+
"""LLM yanitindaki member_ids (v-etiketleri) GERCEK varyant dizelerine cevir;
|
|
98
|
+
her varyant icin tek bir uye olustur. Bilinmeyen etiket -> ham dize (son care)."""
|
|
99
|
+
out: list[dict] = []
|
|
100
|
+
seen: set[str] = set()
|
|
101
|
+
for mid in (cluster.get("member_ids") or []):
|
|
102
|
+
v = by_label.get(str(mid).strip(), str(mid))
|
|
103
|
+
if not v or v in seen:
|
|
104
|
+
continue
|
|
105
|
+
seen.add(v)
|
|
106
|
+
out.append({"id": v, variant_key: [v]})
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _member_variants(m: dict, key: str) -> list:
|
|
111
|
+
"""Bir üyenin varyant listesi; boşsa üyenin kendi 'id'sine düş (LLM üyeleri
|
|
112
|
+
varyantı çoğu zaman 'id' alanında taşır)."""
|
|
113
|
+
vs = m.get(key) or ([m["id"]] if m.get("id") else [])
|
|
114
|
+
return [v for v in vs if v]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _apply_c1_map(df: pd.DataFrame, cols: list[str], fn) -> int:
|
|
118
|
+
"""C1/C1raw kolonlarına fn uygula ve GERÇEKTEN değişen satır sayısını döndür.
|
|
119
|
+
|
|
120
|
+
Eskiden affected=len(mapping) idi: eşleşme hiç tutmasa bile sıfır-olmayan bir
|
|
121
|
+
sayı raporluyordu (kullanıcıya '109 değişti' der ama veri değişmezdi). Artık
|
|
122
|
+
yalnız fiilen değişen satırlar sayılır (herhangi bir kolonda)."""
|
|
123
|
+
changed = pd.Series(False, index=df.index)
|
|
124
|
+
for col in cols:
|
|
125
|
+
before = df[col].astype(str)
|
|
126
|
+
after = before.apply(fn)
|
|
127
|
+
df[col] = after
|
|
128
|
+
changed = changed | (after != before)
|
|
129
|
+
return int(changed.sum())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _client() -> DeepSeekClient:
|
|
133
|
+
api_key = settings.effective_llm_api_key
|
|
134
|
+
if not api_key:
|
|
135
|
+
raise HTTPException(400, "llm_key_missing")
|
|
136
|
+
return DeepSeekClient(
|
|
137
|
+
api_key=api_key,
|
|
138
|
+
base_url=settings.effective_llm_base_url,
|
|
139
|
+
model=settings.effective_llm_model,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _snapshot(project_dir: Path, df: pd.DataFrame, kind: str) -> Path:
|
|
144
|
+
snaps = project_dir / "snapshots"
|
|
145
|
+
snaps.mkdir(exist_ok=True)
|
|
146
|
+
stamp = time.strftime("%Y%m%d_%H%M%S")
|
|
147
|
+
p = snaps / f"pre_{kind}_{stamp}.xlsx"
|
|
148
|
+
df.to_excel(p, index=False)
|
|
149
|
+
return p
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── ORCID çözümü SENKRON ağ I/O yapar (orcids_for_candidate → fetch_orcids_for_doi →
|
|
153
|
+
# requests). Bu yüzden ASLA event loop üzerinde await'siz çağrılmamalı: aksi halde scan
|
|
154
|
+
# sırasında loop bloklanır ve cancel/navigasyon/export DONAR (test bulgusu #7'nin ayrı
|
|
155
|
+
# kök nedeni). Aşağıdaki yardımcılar asyncio.to_thread ile thread'e alınır; should_cancel
|
|
156
|
+
# ile iptalde erken çıkarlar. ──
|
|
157
|
+
def _resolve_member_orcid_sets(df, members, email, allow_api, should_cancel=None) -> list[set]:
|
|
158
|
+
"""Üye başına ORCID seti (df-first + opsiyonel anlık API). Thread'de çalıştırılmalı."""
|
|
159
|
+
out: list[set] = []
|
|
160
|
+
for m in members:
|
|
161
|
+
if should_cancel and should_cancel():
|
|
162
|
+
break
|
|
163
|
+
out.append(orcid_mod.orcids_for_candidate(df, m, email=email, allow_api=allow_api))
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _resolve_split_group_sets(df, groups, name, email, allow_api, should_cancel=None) -> list[set]:
|
|
168
|
+
"""Bir split önerisinin grup-başına ORCID setleri. Thread'de çalıştırılmalı."""
|
|
169
|
+
out: list[set] = []
|
|
170
|
+
for g in groups:
|
|
171
|
+
if should_cancel and should_cancel():
|
|
172
|
+
break
|
|
173
|
+
cand = {"name_variants": [name], "records": g.get("records", [])}
|
|
174
|
+
out.append(orcid_mod.orcids_for_candidate(df, cand, email=email, allow_api=allow_api))
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _compute_author_splits(ctx: JobContext, project_dir: Path, df) -> list[dict]:
|
|
179
|
+
"""Yazar AYRIŞTIRMA önerileri. Tier 1 (net, alan-ayrık) deterministik;
|
|
180
|
+
Tier 2 (sınırda, alan-yok kayıt var) LLM hakemliğine — LLM 'keep' derse öneri düşer."""
|
|
181
|
+
splits = await run_cpu(build_author_splits, df, should_cancel=lambda: ctx.cancelled)
|
|
182
|
+
if not splits:
|
|
183
|
+
ctx.log("Splitting: no names to split.")
|
|
184
|
+
return []
|
|
185
|
+
|
|
186
|
+
# ── ORCID override: aynı isim, alan-ayrık gruplar gerçekten farklı kişiler mi? ──
|
|
187
|
+
# Grup başına ORCID çöz (df-first, eksikse anlık API). ORCID altın standarttır:
|
|
188
|
+
# • Tek ortak ORCID ≥2 gruba yayılıyorsa → TEK interdisipliner kişi → ASLA bölme.
|
|
189
|
+
# • Gruplar farklı ORCID'lerdeyse → kesin farklı kişiler → güçlü split (LLM'siz).
|
|
190
|
+
email = settings.crossref_email or None
|
|
191
|
+
allow_api = settings.disambiguation_orcid_lookup
|
|
192
|
+
orcid_keep = 0
|
|
193
|
+
orcid_split = 0
|
|
194
|
+
for s in splits:
|
|
195
|
+
if ctx.cancelled:
|
|
196
|
+
break
|
|
197
|
+
# SENKRON ağ I/O → thread'e al (event loop'u bloklama; cancel/nav/export donmasın).
|
|
198
|
+
group_sets = await asyncio.to_thread(
|
|
199
|
+
_resolve_split_group_sets, df, s.get("groups", []), s["name"], email, allow_api,
|
|
200
|
+
lambda: ctx.cancelled,
|
|
201
|
+
)
|
|
202
|
+
known = [gs for gs in group_sets if gs]
|
|
203
|
+
if len(known) >= 2:
|
|
204
|
+
# Tüm bilinen gruplar ortak bir ORCID paylaşıyor mu?
|
|
205
|
+
if set.intersection(*known):
|
|
206
|
+
s["decision"] = "keep" # aynı kişi → bölme
|
|
207
|
+
s["source"] = "orcid"
|
|
208
|
+
s["reason"] = "Same ORCID across groups — one interdisciplinary person"
|
|
209
|
+
orcid_keep += 1
|
|
210
|
+
else:
|
|
211
|
+
s["decision"] = "split" # farklı ORCID'ler → kesin böl
|
|
212
|
+
s["source"] = "orcid"
|
|
213
|
+
s["tier"] = 1
|
|
214
|
+
s["confidence"] = 1.0
|
|
215
|
+
s["reason"] = "Different ORCID per group — distinct people"
|
|
216
|
+
orcid_split += 1
|
|
217
|
+
if orcid_keep or orcid_split:
|
|
218
|
+
ctx.log(f"ORCID split: {orcid_split} confirmed, {orcid_keep} kept (same person)")
|
|
219
|
+
|
|
220
|
+
# ORCID kararı verilmemiş (source != 'orcid') öneriler eski akışa düşer
|
|
221
|
+
undecided = [s for s in splits if s.get("source") != "orcid"]
|
|
222
|
+
tier1 = [s for s in undecided if s.get("tier") == 1]
|
|
223
|
+
tier2 = [s for s in undecided if s.get("tier") == 2]
|
|
224
|
+
for s in tier1:
|
|
225
|
+
s["source"] = "deterministic"
|
|
226
|
+
s["decision"] = "split"
|
|
227
|
+
ctx.log(f"Splitting — Tier 1 (auto): {len(tier1)}, Tier 2 (borderline): {len(tier2)}")
|
|
228
|
+
|
|
229
|
+
if tier2:
|
|
230
|
+
client = None
|
|
231
|
+
try:
|
|
232
|
+
client = _client()
|
|
233
|
+
except HTTPException:
|
|
234
|
+
client = None # API anahtarı yoksa manuel incelemeye bırak
|
|
235
|
+
if client is not None:
|
|
236
|
+
cache = DisambiguationCache(project_dir)
|
|
237
|
+
try:
|
|
238
|
+
for s in tier2:
|
|
239
|
+
if ctx.cancelled:
|
|
240
|
+
break
|
|
241
|
+
payload = {"task": "author_split", "name": s["name"],
|
|
242
|
+
"groups": [{"fields": g["fields"]} for g in s["groups"]]}
|
|
243
|
+
ck = cache.hash_key(payload)
|
|
244
|
+
resp = cache.get(ck)
|
|
245
|
+
if resp is None:
|
|
246
|
+
try:
|
|
247
|
+
resp = await client.chat_json_async(AUTHOR_SPLIT_SYSTEM_PROMPT, payload)
|
|
248
|
+
cache.set(ck, resp)
|
|
249
|
+
except Exception as e:
|
|
250
|
+
ctx.log(f"Split LLM error ({s['name']}): {e}")
|
|
251
|
+
resp = {}
|
|
252
|
+
s["source"] = "llm"
|
|
253
|
+
s["decision"] = resp.get("decision", "review") # split | keep | review
|
|
254
|
+
s["confidence"] = resp.get("confidence")
|
|
255
|
+
if resp.get("reason"):
|
|
256
|
+
s["reason"] = f"{s['reason']} | AI: {resp['reason']}"
|
|
257
|
+
finally:
|
|
258
|
+
cache.close()
|
|
259
|
+
else:
|
|
260
|
+
for s in tier2:
|
|
261
|
+
s["source"] = "manual"
|
|
262
|
+
s["decision"] = "review"
|
|
263
|
+
|
|
264
|
+
# 'keep' (LLM ya da ORCID: tek disiplinlerarası kişi) önerileri listeden çıkar.
|
|
265
|
+
# ORCID ile karar verilenler (split) de dahil — tüm splits üzerinden filtrele.
|
|
266
|
+
return [s for s in splits if s.get("decision") != "keep"]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def run_author_disambiguation(
|
|
270
|
+
ctx: JobContext, project_id: str, mode: str = "auto",
|
|
271
|
+
max_records: int | None = None,
|
|
272
|
+
) -> dict[str, Any]:
|
|
273
|
+
project_dir = _project_dir(project_id)
|
|
274
|
+
df = await asyncio.to_thread(_load_merged, project_id)
|
|
275
|
+
ctx.log(f"Loaded: {len(df)} records")
|
|
276
|
+
if max_records and max_records > 0 and len(df) > max_records:
|
|
277
|
+
df = df.head(max_records)
|
|
278
|
+
ctx.log(f"Test/cost mode: limited to first {max_records} records")
|
|
279
|
+
ctx.log(f"Mode: {mode}")
|
|
280
|
+
ctx.progress(0.05)
|
|
281
|
+
|
|
282
|
+
# 1) Blocking
|
|
283
|
+
ctx.log("Blocking + similarity analysis...")
|
|
284
|
+
blocks = await run_cpu(
|
|
285
|
+
build_author_blocks, df,
|
|
286
|
+
settings.disambiguation_blocking_threshold, mode,
|
|
287
|
+
settings.disambiguation_auto_approve_threshold,
|
|
288
|
+
should_cancel=lambda: ctx.cancelled,
|
|
289
|
+
)
|
|
290
|
+
ctx.log(f"{len(blocks)} candidate merge blocks found")
|
|
291
|
+
ctx.progress(0.15)
|
|
292
|
+
|
|
293
|
+
all_clusters: list[dict] = []
|
|
294
|
+
all_uncertain: list[dict] = []
|
|
295
|
+
|
|
296
|
+
# 1.5) Tier 1 — deterministik auto-merge önerileri (LLM'siz, confidence=1.0)
|
|
297
|
+
auto_count = 0
|
|
298
|
+
for block in blocks:
|
|
299
|
+
for ac in block.get("auto", []):
|
|
300
|
+
variants = ac.get("name_variants", [])
|
|
301
|
+
all_clusters.append({
|
|
302
|
+
"cluster_id": ac["id"],
|
|
303
|
+
"block_key": block["key"],
|
|
304
|
+
"members": [ac],
|
|
305
|
+
"canonical": variants[0] if variants else None,
|
|
306
|
+
"confidence": 1.0,
|
|
307
|
+
"source": "deterministic",
|
|
308
|
+
"tier": 1,
|
|
309
|
+
"reason": "High similarity + shared context (automatic)",
|
|
310
|
+
})
|
|
311
|
+
auto_count += 1
|
|
312
|
+
if auto_count:
|
|
313
|
+
ctx.log(f"Tier 1 (automatic, no LLM): {auto_count} proposals")
|
|
314
|
+
|
|
315
|
+
# ORCID write-back istatistiği (Part 2d)
|
|
316
|
+
orcid_stats = {"rows_filled": 0, "dois_fetched": 0}
|
|
317
|
+
email = settings.crossref_email or None
|
|
318
|
+
allow_api = settings.disambiguation_orcid_lookup
|
|
319
|
+
|
|
320
|
+
# 2) Tier 2 — belirsiz bloklar. ÖNCE ORCID kararı (df-first, eksikse anlık API),
|
|
321
|
+
# sonra gerekiyorsa LLM. ORCID altın standart: aynı=merge, farklı=ayrı.
|
|
322
|
+
llm_blocks = [b for b in blocks if b.get("members")]
|
|
323
|
+
ctx.log(f"Tier 2 (review): {len(llm_blocks)} blocks")
|
|
324
|
+
orcid_merges = 0
|
|
325
|
+
orcid_conflicts = 0
|
|
326
|
+
if llm_blocks:
|
|
327
|
+
client = None
|
|
328
|
+
cache = DisambiguationCache(project_dir)
|
|
329
|
+
try:
|
|
330
|
+
for i, block in enumerate(llm_blocks):
|
|
331
|
+
if ctx.cancelled:
|
|
332
|
+
ctx.log("Cancelled")
|
|
333
|
+
break
|
|
334
|
+
|
|
335
|
+
members = block["members"]
|
|
336
|
+
# 2.1) ORCID ön-pass — üye başına ORCID setini çöz (df-first + on-demand).
|
|
337
|
+
# SENKRON ağ I/O → thread'e al (event loop'u bloklama; aksi halde scan
|
|
338
|
+
# sırasında cancel/navigasyon/export DONAR — #7 ek kök neden).
|
|
339
|
+
orcid_sets = await asyncio.to_thread(
|
|
340
|
+
_resolve_member_orcid_sets, df, members, email, allow_api,
|
|
341
|
+
lambda: ctx.cancelled,
|
|
342
|
+
)
|
|
343
|
+
verdict = orcid_mod.classify_by_orcid(orcid_sets)
|
|
344
|
+
|
|
345
|
+
if verdict == "merge":
|
|
346
|
+
# Tüm ORCID-bilinen üyeler aynı kişi → Tier 1 auto-merge (LLM'siz)
|
|
347
|
+
shared = sorted(set.intersection(*[s for s in orcid_sets if s]))
|
|
348
|
+
variants = sorted({v for m in members for v in (m.get("name_variants") or [])})
|
|
349
|
+
if len(set(variants)) >= 2:
|
|
350
|
+
all_clusters.append({
|
|
351
|
+
"cluster_id": f"{block['key']}__orcid",
|
|
352
|
+
"block_key": block["key"],
|
|
353
|
+
"members": members,
|
|
354
|
+
"canonical": variants[0] if variants else None,
|
|
355
|
+
"confidence": 1.0,
|
|
356
|
+
"source": "orcid",
|
|
357
|
+
"tier": 1,
|
|
358
|
+
"reason": f"Same ORCID ({', '.join(shared)})",
|
|
359
|
+
})
|
|
360
|
+
orcid_merges += 1
|
|
361
|
+
ctx.progress(0.15 + 0.8 * ((i + 1) / len(llm_blocks)))
|
|
362
|
+
continue # LLM'e gitme
|
|
363
|
+
|
|
364
|
+
if verdict == "conflict":
|
|
365
|
+
# Ayrık ORCID'ler → farklı kişiler → birleştirme YOK (LLM'e gitme)
|
|
366
|
+
orcid_conflicts += 1
|
|
367
|
+
block_names = sorted({v for m in members for v in (m.get("name_variants") or [])})
|
|
368
|
+
all_uncertain.append({
|
|
369
|
+
"block_key": block["key"],
|
|
370
|
+
"names": block_names,
|
|
371
|
+
"reason": "Different ORCIDs — distinct people (not merged)",
|
|
372
|
+
})
|
|
373
|
+
ctx.progress(0.15 + 0.8 * ((i + 1) / len(llm_blocks)))
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
# 2.2) ORCID belirsiz → LLM hakemliği (mevcut akış). LLM yapılandırılmamışsa
|
|
377
|
+
# bloğu manuel incelemeye bırak (job çökmesin).
|
|
378
|
+
if client is None:
|
|
379
|
+
try:
|
|
380
|
+
client = _client()
|
|
381
|
+
except HTTPException:
|
|
382
|
+
client = False # anahtar yok — bu çalıştırmada LLM kullanılmaz
|
|
383
|
+
if not client:
|
|
384
|
+
block_names = sorted({v for m in members for v in (m.get("name_variants") or [])})
|
|
385
|
+
all_uncertain.append({
|
|
386
|
+
"block_key": block["key"], "names": block_names,
|
|
387
|
+
"reason": "No ORCID and no LLM configured — manual review",
|
|
388
|
+
})
|
|
389
|
+
ctx.progress(0.15 + 0.8 * ((i + 1) / len(llm_blocks)))
|
|
390
|
+
continue
|
|
391
|
+
items, by_label = _label_for_llm(members, "name_variants", ("affiliations", "coauthors", "year_range"))
|
|
392
|
+
payload = {"task": "author_disambiguation", "candidates": items}
|
|
393
|
+
cache_key = cache.hash_key(payload)
|
|
394
|
+
cached = cache.get(cache_key)
|
|
395
|
+
if cached:
|
|
396
|
+
resp = cached
|
|
397
|
+
ctx.log(f"[{i+1}/{len(llm_blocks)}] cache hit ({block['key']})")
|
|
398
|
+
else:
|
|
399
|
+
try:
|
|
400
|
+
resp = await client.chat_json_async(AUTHOR_SYSTEM_PROMPT, payload)
|
|
401
|
+
cache.set(cache_key, resp)
|
|
402
|
+
ctx.log(f"[{i+1}/{len(llm_blocks)}] LLM ({block['key']}): {len(resp.get('clusters', []))} cluster")
|
|
403
|
+
except Exception as e:
|
|
404
|
+
ctx.log(f"[{i+1}/{len(llm_blocks)}] ERROR: {e}")
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
for c in resp.get("clusters", []):
|
|
408
|
+
c["block_key"] = block["key"]
|
|
409
|
+
c["cluster_id"] = f"{block['key']}__{c.get('cluster_id', 'c')}" # global benzersiz (bloklar arası c1/c2 çakışmasın)
|
|
410
|
+
c["source"] = "llm"
|
|
411
|
+
c["tier"] = 2
|
|
412
|
+
# member_ids (v-etiketleri) -> gercek isim varyantlari
|
|
413
|
+
c["members"] = _members_from_labels(c, by_label, "name_variants")
|
|
414
|
+
if len(_distinct_variants(c)) < 2:
|
|
415
|
+
continue # tek varyant — birleştirilecek bir şey yok, boş kart olmasın
|
|
416
|
+
all_clusters.append(c)
|
|
417
|
+
for u in resp.get("uncertain", []):
|
|
418
|
+
u["block_key"] = block["key"]
|
|
419
|
+
all_uncertain.append(u)
|
|
420
|
+
|
|
421
|
+
ctx.progress(0.15 + 0.8 * ((i + 1) / len(llm_blocks)))
|
|
422
|
+
finally:
|
|
423
|
+
cache.close()
|
|
424
|
+
if orcid_merges or orcid_conflicts:
|
|
425
|
+
ctx.log(f"ORCID: {orcid_merges} auto-merge, {orcid_conflicts} conflict (distinct people)")
|
|
426
|
+
|
|
427
|
+
ctx.progress(0.7)
|
|
428
|
+
|
|
429
|
+
# 3) Ayrıştırma (split) önerileri — aynı isim, alan bakımından ayrık gruplar
|
|
430
|
+
ctx.log("Author splitting analysis (same spelling, different field)...")
|
|
431
|
+
splits = await _compute_author_splits(ctx, project_dir, df)
|
|
432
|
+
ctx.progress(0.9)
|
|
433
|
+
|
|
434
|
+
# 4) Önerileri kaydet (henüz uygulanmadı — onay bekliyor)
|
|
435
|
+
proposals_path = project_dir / "disambiguation_authors.json"
|
|
436
|
+
proposals = {
|
|
437
|
+
"kind": "authors",
|
|
438
|
+
"generated_at": time.time(),
|
|
439
|
+
"auto_approve_threshold": settings.disambiguation_auto_approve_threshold,
|
|
440
|
+
"clusters": all_clusters,
|
|
441
|
+
"splits": splits,
|
|
442
|
+
"uncertain": all_uncertain,
|
|
443
|
+
}
|
|
444
|
+
proposals_path.write_text(json.dumps(proposals, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
445
|
+
ctx.log(
|
|
446
|
+
f"Merging: {len(all_clusters)} proposals ({auto_count} automatic + {len(all_clusters) - auto_count} LLM); "
|
|
447
|
+
f"Splitting: {len(splits)} proposals"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# 5) Write-back — ambigu satırlarda DOI'den çekilen OI/RI/ROR/CC'yi veriye kalıcı
|
|
451
|
+
# yaz (fetch boşa gitmesin; sonra Fill-all gerekmesin). Yalnız boş hücrelere.
|
|
452
|
+
snap_rel = None
|
|
453
|
+
if settings.disambiguation_orcid_persist and not ctx.cancelled:
|
|
454
|
+
ambiguous_rows: list[int] = []
|
|
455
|
+
for b in llm_blocks:
|
|
456
|
+
for m in b.get("members", []):
|
|
457
|
+
ambiguous_rows.extend(m.get("records", []))
|
|
458
|
+
for s in splits:
|
|
459
|
+
for g in s.get("groups", []):
|
|
460
|
+
ambiguous_rows.extend(g.get("records", []))
|
|
461
|
+
if ambiguous_rows:
|
|
462
|
+
df_before = df.copy(deep=True)
|
|
463
|
+
wb = await asyncio.to_thread(
|
|
464
|
+
orcid_mod.enrich_rows_from_doi, df, ambiguous_rows,
|
|
465
|
+
email=(settings.crossref_email or None),
|
|
466
|
+
)
|
|
467
|
+
orcid_stats["rows_filled"] = wb["rows_filled"]
|
|
468
|
+
orcid_stats["dois_fetched"] = wb["dois_fetched"]
|
|
469
|
+
if wb["rows_filled"]:
|
|
470
|
+
src = merger.merged_dataset_path(project_id)
|
|
471
|
+
if src is not None:
|
|
472
|
+
snap_rel = _snapshot(project_dir, df_before, "orcid_enrich")
|
|
473
|
+
await asyncio.to_thread(df.to_excel, src, index=False)
|
|
474
|
+
try:
|
|
475
|
+
from services.filter_engine import _DF_CACHE
|
|
476
|
+
_DF_CACHE.clear()
|
|
477
|
+
except Exception:
|
|
478
|
+
pass
|
|
479
|
+
storage.touch_project(project_id)
|
|
480
|
+
ctx.log(f"ORCID write-back: {wb['rows_filled']} rows enriched (OI/ROR/CC)")
|
|
481
|
+
|
|
482
|
+
ctx.progress(1.0)
|
|
483
|
+
return {
|
|
484
|
+
"task": "authors",
|
|
485
|
+
"candidates": int(sum(len(b.get("members", [])) + len(b.get("auto", [])) for b in blocks)),
|
|
486
|
+
"clusters_proposed": len(all_clusters),
|
|
487
|
+
"splits_proposed": len(splits),
|
|
488
|
+
"auto": auto_count,
|
|
489
|
+
"uncertain": len(all_uncertain),
|
|
490
|
+
"orcid_rows_filled": orcid_stats["rows_filled"],
|
|
491
|
+
"dois_fetched": orcid_stats["dois_fetched"],
|
|
492
|
+
"snapshot": str(snap_rel.relative_to(storage.settings.storage_path)) if snap_rel else None,
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
async def run_affiliation_disambiguation(
|
|
497
|
+
ctx: JobContext, project_id: str, mode: str = "auto",
|
|
498
|
+
max_records: int | None = None,
|
|
499
|
+
) -> dict[str, Any]:
|
|
500
|
+
project_dir = _project_dir(project_id)
|
|
501
|
+
df = await asyncio.to_thread(_load_merged, project_id)
|
|
502
|
+
ctx.log(f"Loaded: {len(df)} records")
|
|
503
|
+
if max_records and max_records > 0 and len(df) > max_records:
|
|
504
|
+
df = df.head(max_records)
|
|
505
|
+
ctx.log(f"Test/cost mode: limited to first {max_records} records")
|
|
506
|
+
ctx.log(f"Mode: {mode}")
|
|
507
|
+
ctx.progress(0.05)
|
|
508
|
+
|
|
509
|
+
ctx.log("Affiliation blocking + similarity analysis...")
|
|
510
|
+
blocks = await run_cpu(
|
|
511
|
+
build_affiliation_blocks, df,
|
|
512
|
+
settings.disambiguation_blocking_threshold, mode,
|
|
513
|
+
settings.disambiguation_auto_approve_threshold,
|
|
514
|
+
should_cancel=lambda: ctx.cancelled,
|
|
515
|
+
)
|
|
516
|
+
ctx.log(f"{len(blocks)} candidate blocks found")
|
|
517
|
+
ctx.progress(0.15)
|
|
518
|
+
|
|
519
|
+
if not blocks:
|
|
520
|
+
ctx.log("No affiliation variants to merge.")
|
|
521
|
+
return {"task": "affiliations", "candidates": 0, "clusters": [], "uncertain": []}
|
|
522
|
+
|
|
523
|
+
all_clusters: list[dict] = []
|
|
524
|
+
all_uncertain: list[dict] = []
|
|
525
|
+
|
|
526
|
+
# Tier 1 — deterministik (yüksek string benzerliği, LLM'siz)
|
|
527
|
+
auto_count = 0
|
|
528
|
+
for block in blocks:
|
|
529
|
+
for ac in block.get("auto", []):
|
|
530
|
+
variants = ac.get("variants", [])
|
|
531
|
+
all_clusters.append({
|
|
532
|
+
"cluster_id": ac["id"],
|
|
533
|
+
"block_key": block["key"],
|
|
534
|
+
"members": [ac],
|
|
535
|
+
"canonical_name": variants[0] if variants else None,
|
|
536
|
+
"confidence": 1.0,
|
|
537
|
+
"source": "deterministic",
|
|
538
|
+
"tier": 1,
|
|
539
|
+
"reason": "High string similarity (automatic)",
|
|
540
|
+
})
|
|
541
|
+
auto_count += 1
|
|
542
|
+
if auto_count:
|
|
543
|
+
ctx.log(f"Tier 1 (automatic, no LLM): {auto_count} proposals")
|
|
544
|
+
|
|
545
|
+
# Tier 2 — yalnız belirsiz blokları LLM'e
|
|
546
|
+
llm_blocks = [b for b in blocks if b.get("members")]
|
|
547
|
+
ctx.log(f"Tier 2 (LLM review): {len(llm_blocks)} blocks")
|
|
548
|
+
if llm_blocks:
|
|
549
|
+
client = _client()
|
|
550
|
+
cache = DisambiguationCache(project_dir)
|
|
551
|
+
try:
|
|
552
|
+
for i, block in enumerate(llm_blocks):
|
|
553
|
+
if ctx.cancelled:
|
|
554
|
+
break
|
|
555
|
+
items, by_label = _label_for_llm(block["members"], "variants")
|
|
556
|
+
payload = {"task": "affiliation_normalization", "candidates": items}
|
|
557
|
+
cache_key = cache.hash_key(payload)
|
|
558
|
+
cached = cache.get(cache_key)
|
|
559
|
+
if cached:
|
|
560
|
+
resp = cached
|
|
561
|
+
else:
|
|
562
|
+
try:
|
|
563
|
+
resp = await client.chat_json_async(AFFILIATION_SYSTEM_PROMPT, payload)
|
|
564
|
+
cache.set(cache_key, resp)
|
|
565
|
+
except Exception as e:
|
|
566
|
+
ctx.log(f"[{i+1}/{len(llm_blocks)}] ERROR: {e}")
|
|
567
|
+
continue
|
|
568
|
+
for c in resp.get("clusters", []):
|
|
569
|
+
c["block_key"] = block["key"]
|
|
570
|
+
c["cluster_id"] = f"{block['key']}__{c.get('cluster_id', 'c')}" # global benzersiz (bloklar arası c1/c2 çakışmasın)
|
|
571
|
+
c["source"] = "llm"
|
|
572
|
+
c["tier"] = 2
|
|
573
|
+
c["members"] = _members_from_labels(c, by_label, "variants")
|
|
574
|
+
if len(_distinct_variants(c)) < 2:
|
|
575
|
+
continue # tek varyant — birleştirilecek bir şey yok, boş kart olmasın
|
|
576
|
+
all_clusters.append(c)
|
|
577
|
+
for u in resp.get("uncertain", []):
|
|
578
|
+
u["block_key"] = block["key"]
|
|
579
|
+
all_uncertain.append(u)
|
|
580
|
+
ctx.progress(0.15 + 0.8 * ((i + 1) / len(llm_blocks)))
|
|
581
|
+
finally:
|
|
582
|
+
cache.close()
|
|
583
|
+
|
|
584
|
+
proposals_path = project_dir / "disambiguation_affiliations.json"
|
|
585
|
+
proposals = {
|
|
586
|
+
"kind": "affiliations",
|
|
587
|
+
"generated_at": time.time(),
|
|
588
|
+
"clusters": all_clusters,
|
|
589
|
+
"uncertain": all_uncertain,
|
|
590
|
+
}
|
|
591
|
+
proposals_path.write_text(json.dumps(proposals, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
592
|
+
ctx.log(f"{len(all_clusters)} proposals saved ({auto_count} automatic)")
|
|
593
|
+
ctx.progress(1.0)
|
|
594
|
+
return {
|
|
595
|
+
"task": "affiliations",
|
|
596
|
+
"clusters_proposed": len(all_clusters),
|
|
597
|
+
"auto": auto_count,
|
|
598
|
+
"uncertain": len(all_uncertain),
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
async def _run_cluster_tool(
|
|
603
|
+
ctx: JobContext, project_id: str, *,
|
|
604
|
+
kind: str, blocks_builder, system_prompt: str, task: str,
|
|
605
|
+
tier1_reason: str, mode: str = "auto", max_records: int | None = None,
|
|
606
|
+
) -> dict[str, Any]:
|
|
607
|
+
"""Affiliation-deseni standartlaştırma çalıştırıcısı (ülke / org rollup için ortak)."""
|
|
608
|
+
project_dir = _project_dir(project_id)
|
|
609
|
+
df = await asyncio.to_thread(_load_merged, project_id)
|
|
610
|
+
ctx.log(f"Loaded: {len(df)} records")
|
|
611
|
+
if max_records and max_records > 0 and len(df) > max_records:
|
|
612
|
+
df = df.head(max_records)
|
|
613
|
+
ctx.progress(0.05)
|
|
614
|
+
|
|
615
|
+
blocks = await run_cpu(
|
|
616
|
+
blocks_builder, df,
|
|
617
|
+
settings.disambiguation_blocking_threshold, mode,
|
|
618
|
+
settings.disambiguation_auto_approve_threshold,
|
|
619
|
+
should_cancel=lambda: ctx.cancelled,
|
|
620
|
+
)
|
|
621
|
+
ctx.log(f"{len(blocks)} candidate blocks")
|
|
622
|
+
ctx.progress(0.15)
|
|
623
|
+
|
|
624
|
+
all_clusters: list[dict] = []
|
|
625
|
+
all_uncertain: list[dict] = []
|
|
626
|
+
auto_count = 0
|
|
627
|
+
for block in blocks:
|
|
628
|
+
for ac in block.get("auto", []):
|
|
629
|
+
variants = ac.get("variants", [])
|
|
630
|
+
all_clusters.append({
|
|
631
|
+
"cluster_id": ac["id"],
|
|
632
|
+
"block_key": block["key"],
|
|
633
|
+
"members": [ac],
|
|
634
|
+
"canonical_name": ac.get("canonical_name") or (variants[0] if variants else None),
|
|
635
|
+
"confidence": 1.0,
|
|
636
|
+
"source": "deterministic",
|
|
637
|
+
"tier": 1,
|
|
638
|
+
"reason": tier1_reason,
|
|
639
|
+
})
|
|
640
|
+
auto_count += 1
|
|
641
|
+
if auto_count:
|
|
642
|
+
ctx.log(f"Tier 1 (automatic): {auto_count} proposals")
|
|
643
|
+
|
|
644
|
+
llm_blocks = [b for b in blocks if b.get("members")]
|
|
645
|
+
if llm_blocks:
|
|
646
|
+
client = _client()
|
|
647
|
+
cache = DisambiguationCache(project_dir)
|
|
648
|
+
try:
|
|
649
|
+
for i, block in enumerate(llm_blocks):
|
|
650
|
+
if ctx.cancelled:
|
|
651
|
+
break
|
|
652
|
+
items, by_label = _label_for_llm(block["members"], "variants")
|
|
653
|
+
payload = {"task": task, "candidates": items}
|
|
654
|
+
ck = cache.hash_key(payload)
|
|
655
|
+
cached = cache.get(ck)
|
|
656
|
+
if cached:
|
|
657
|
+
resp = cached
|
|
658
|
+
else:
|
|
659
|
+
try:
|
|
660
|
+
resp = await client.chat_json_async(system_prompt, payload)
|
|
661
|
+
cache.set(ck, resp)
|
|
662
|
+
except Exception as e:
|
|
663
|
+
ctx.log(f"[{i+1}/{len(llm_blocks)}] ERROR: {e}")
|
|
664
|
+
continue
|
|
665
|
+
for c in resp.get("clusters", []):
|
|
666
|
+
c["block_key"] = block["key"]
|
|
667
|
+
c["cluster_id"] = f"{block['key']}__{c.get('cluster_id', 'c')}" # global benzersiz (bloklar arası c1/c2 çakışmasın)
|
|
668
|
+
c["source"] = "llm"
|
|
669
|
+
c["tier"] = 2
|
|
670
|
+
c["members"] = _members_from_labels(c, by_label, "variants")
|
|
671
|
+
# Country'de tek token geçerlidir (bir ülke yazımını normalize et veya
|
|
672
|
+
# bir kuruma ülke ekle); diğerlerinde (org) birleştirme için >=2 gerekir.
|
|
673
|
+
if kind != "countries" and len(_distinct_variants(c)) < 2:
|
|
674
|
+
continue # tek varyant — birleştirilecek bir şey yok, boş kart olmasın
|
|
675
|
+
all_clusters.append(c)
|
|
676
|
+
for u in resp.get("uncertain", []):
|
|
677
|
+
u["block_key"] = block["key"]
|
|
678
|
+
all_uncertain.append(u)
|
|
679
|
+
ctx.progress(0.15 + 0.8 * ((i + 1) / len(llm_blocks)))
|
|
680
|
+
finally:
|
|
681
|
+
cache.close()
|
|
682
|
+
|
|
683
|
+
(project_dir / f"disambiguation_{kind}.json").write_text(
|
|
684
|
+
json.dumps({"kind": kind, "generated_at": time.time(), "clusters": all_clusters, "uncertain": all_uncertain},
|
|
685
|
+
ensure_ascii=False, indent=2), encoding="utf-8")
|
|
686
|
+
ctx.progress(1.0)
|
|
687
|
+
ctx.log(f"{len(all_clusters)} proposals saved ({auto_count} automatic)")
|
|
688
|
+
return {"task": kind, "clusters_proposed": len(all_clusters), "auto": auto_count, "uncertain": len(all_uncertain)}
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
async def run_country_standardization(ctx: JobContext, project_id: str, mode: str = "auto", max_records: int | None = None) -> dict[str, Any]:
|
|
692
|
+
return await _run_cluster_tool(
|
|
693
|
+
ctx, project_id, kind="countries", blocks_builder=build_country_blocks,
|
|
694
|
+
system_prompt=COUNTRY_SYSTEM_PROMPT, task="country_standardization",
|
|
695
|
+
tier1_reason="Dictionary match (automatic)", mode=mode, max_records=max_records,
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
async def run_org_rollup(ctx: JobContext, project_id: str, mode: str = "auto", max_records: int | None = None) -> dict[str, Any]:
|
|
700
|
+
return await _run_cluster_tool(
|
|
701
|
+
ctx, project_id, kind="organizations", blocks_builder=build_org_rollup,
|
|
702
|
+
system_prompt=ORG_ROLLUP_SYSTEM_PROMPT, task="org_rollup",
|
|
703
|
+
tier1_reason="Address parse -> parent institution (automatic)", mode=mode, max_records=max_records,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def list_proposals(project_id: str, kind: str) -> dict[str, Any]:
|
|
708
|
+
project_dir = _project_dir(project_id)
|
|
709
|
+
fname = f"disambiguation_{kind}.json"
|
|
710
|
+
p = project_dir / fname
|
|
711
|
+
if not p.exists():
|
|
712
|
+
return {"kind": kind, "clusters": [], "uncertain": []}
|
|
713
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def apply_clusters(project_id: str, kind: str, approved: list[dict]) -> dict[str, Any]:
|
|
717
|
+
"""Onaylanmış cluster'ları orijinal datasete uygula.
|
|
718
|
+
|
|
719
|
+
approved: list of {member_ids, canonical?: str}
|
|
720
|
+
"""
|
|
721
|
+
project_dir = _project_dir(project_id)
|
|
722
|
+
src = merger.merged_dataset_path(project_id)
|
|
723
|
+
if src is None:
|
|
724
|
+
raise HTTPException(409, "no_merged_data")
|
|
725
|
+
df = pd.read_excel(src)
|
|
726
|
+
|
|
727
|
+
# NOT: snapshot işin SONUNDA, yalnız gerçekten değişiklik yapılacaksa (affected>0)
|
|
728
|
+
# alınır — 'sıfır değişiklik' apply'ları boş snapshot çöplüğü üretmesin.
|
|
729
|
+
|
|
730
|
+
# Proposal listesinden member metadata'sını oku
|
|
731
|
+
proposals = list_proposals(project_id, kind)
|
|
732
|
+
cluster_lookup = {c.get("cluster_id"): c for c in proposals.get("clusters", [])}
|
|
733
|
+
|
|
734
|
+
affected = 0
|
|
735
|
+
if kind == "authors":
|
|
736
|
+
# Onay: her cluster için canonical bir isim seç (varsayılan: ilk variant) ve diğerlerini değiştir
|
|
737
|
+
if "AU" not in df.columns:
|
|
738
|
+
raise HTTPException(400, "no_au_column")
|
|
739
|
+
for entry in approved:
|
|
740
|
+
cid = entry.get("cluster_id")
|
|
741
|
+
cluster = cluster_lookup.get(cid)
|
|
742
|
+
if not cluster:
|
|
743
|
+
continue
|
|
744
|
+
# Tüm name_variants'i topla (boşsa üyenin id'sine düş — LLM stub'ları)
|
|
745
|
+
all_variants: list[str] = []
|
|
746
|
+
for m in cluster.get("members", []):
|
|
747
|
+
all_variants.extend(_member_variants(m, "name_variants"))
|
|
748
|
+
canonical = entry.get("canonical") or (all_variants[0] if all_variants else None)
|
|
749
|
+
if not canonical:
|
|
750
|
+
continue
|
|
751
|
+
import re as _re
|
|
752
|
+
|
|
753
|
+
def _nkey(s: str) -> str:
|
|
754
|
+
return _re.sub(r"\s+", " ", str(s or "")).strip().lower()
|
|
755
|
+
|
|
756
|
+
# Boşluk-toleranslı: WoS AU yazımı tutarsız olabilir ('GAO S' vs 'GAO S').
|
|
757
|
+
variant_keys = {_nkey(v) for v in all_variants if _nkey(v) != _nkey(canonical)}
|
|
758
|
+
if not variant_keys:
|
|
759
|
+
continue
|
|
760
|
+
|
|
761
|
+
def _repl(s: str) -> str:
|
|
762
|
+
return ";".join(canonical if _nkey(x) in variant_keys else x.strip() for x in str(s).split(";"))
|
|
763
|
+
|
|
764
|
+
before_col = df["AU"].astype(str)
|
|
765
|
+
df["AU"] = before_col.apply(_repl)
|
|
766
|
+
affected += int((df["AU"] != before_col).sum())
|
|
767
|
+
|
|
768
|
+
elif kind == "affiliations":
|
|
769
|
+
cols = [c for c in ("C1", "C1raw") if c in df.columns]
|
|
770
|
+
if not cols:
|
|
771
|
+
raise HTTPException(400, "no_c1_column")
|
|
772
|
+
from bibex_core.modules.c1_utils import replace_affiliation_in_c1
|
|
773
|
+
mapping: dict[str, str] = {} # varyant affiliation -> kanonik (bileşen-tam)
|
|
774
|
+
for entry in approved:
|
|
775
|
+
cluster = cluster_lookup.get(entry.get("cluster_id"))
|
|
776
|
+
if not cluster:
|
|
777
|
+
continue
|
|
778
|
+
all_variants: list[str] = []
|
|
779
|
+
for m in cluster.get("members", []):
|
|
780
|
+
all_variants.extend(_member_variants(m, "variants"))
|
|
781
|
+
canonical = entry.get("canonical") or cluster.get("canonical_name") or (all_variants[0] if all_variants else None)
|
|
782
|
+
if not canonical:
|
|
783
|
+
continue
|
|
784
|
+
for variant in set(all_variants):
|
|
785
|
+
if variant and variant != canonical:
|
|
786
|
+
mapping[variant] = canonical
|
|
787
|
+
if mapping:
|
|
788
|
+
affected = _apply_c1_map(df, cols, lambda s: replace_affiliation_in_c1(s, mapping))
|
|
789
|
+
|
|
790
|
+
elif kind == "countries":
|
|
791
|
+
cols = [c for c in ("C1", "C1raw") if c in df.columns]
|
|
792
|
+
if not cols:
|
|
793
|
+
raise HTTPException(400, "no_c1_column")
|
|
794
|
+
from bibex_core.modules.c1_utils import replace_country_in_c1, _is_org
|
|
795
|
+
mapping: dict[str, str] = {} # küçük-harf ülke-varyantı -> kanonik ülke
|
|
796
|
+
for entry in approved:
|
|
797
|
+
cluster = cluster_lookup.get(entry.get("cluster_id"))
|
|
798
|
+
if not cluster:
|
|
799
|
+
continue
|
|
800
|
+
canon = entry.get("canonical") or cluster.get("canonical_name")
|
|
801
|
+
if not canon:
|
|
802
|
+
continue
|
|
803
|
+
for m in cluster.get("members", []):
|
|
804
|
+
for v in _member_variants(m, "variants"):
|
|
805
|
+
tok = str(v).strip()
|
|
806
|
+
# GÜVENLİK: kurum tokeni ülke replace'ine girmesin (kurum=ülke karmasını
|
|
807
|
+
# ve C1 bozulmasını önler). Country standardizasyonu YALNIZ ülke yazımını
|
|
808
|
+
# normalize eder; eksik ülke tamamlama ayrı API eylemiyle yapılır.
|
|
809
|
+
if tok and not _is_org(tok):
|
|
810
|
+
mapping[tok.lower()] = canon
|
|
811
|
+
if mapping:
|
|
812
|
+
affected = _apply_c1_map(df, cols, lambda s: replace_country_in_c1(s, mapping))
|
|
813
|
+
|
|
814
|
+
elif kind == "organizations":
|
|
815
|
+
cols = [c for c in ("C1", "C1raw") if c in df.columns]
|
|
816
|
+
if not cols:
|
|
817
|
+
raise HTTPException(400, "no_c1_column")
|
|
818
|
+
from bibex_core.modules.c1_utils import replace_org_in_c1
|
|
819
|
+
mapping: dict[str, str] = {} # tam adres varyantı -> kanonik üst kurum
|
|
820
|
+
for entry in approved:
|
|
821
|
+
cluster = cluster_lookup.get(entry.get("cluster_id"))
|
|
822
|
+
if not cluster:
|
|
823
|
+
continue
|
|
824
|
+
canon = entry.get("canonical") or cluster.get("canonical_name")
|
|
825
|
+
if not canon:
|
|
826
|
+
continue
|
|
827
|
+
for m in cluster.get("members", []):
|
|
828
|
+
for v in _member_variants(m, "variants"):
|
|
829
|
+
if v and str(v).strip():
|
|
830
|
+
mapping[str(v).strip()] = canon
|
|
831
|
+
if mapping:
|
|
832
|
+
affected = _apply_c1_map(df, cols, lambda s: replace_org_in_c1(s, mapping))
|
|
833
|
+
else:
|
|
834
|
+
raise HTTPException(400, f"Bilinmeyen kind: {kind}")
|
|
835
|
+
|
|
836
|
+
# Kaydet (orijinal merged dosyanın üstüne yaz, ama snapshot var)
|
|
837
|
+
# Hiçbir değişiklik yapılmadıysa: snapshot/yazım/audit YOK (boş apply no-op).
|
|
838
|
+
if affected == 0:
|
|
839
|
+
return {"kind": kind, "approved_count": len(approved), "replacements": 0, "snapshot": None}
|
|
840
|
+
|
|
841
|
+
# Değişiklik var → ÖNCE snapshot (df'in güncel hâli yazımdan önce yedeklenir), sonra yaz.
|
|
842
|
+
snap = _snapshot(project_dir, pd.read_excel(src), kind)
|
|
843
|
+
df.to_excel(src, index=False)
|
|
844
|
+
# Filter cache temizle
|
|
845
|
+
from services.filter_engine import _DF_CACHE
|
|
846
|
+
_DF_CACHE.clear()
|
|
847
|
+
|
|
848
|
+
# Audit log
|
|
849
|
+
audit_path = project_dir / f"disambiguation_audit_{kind}.json"
|
|
850
|
+
audit: list[dict] = []
|
|
851
|
+
if audit_path.exists():
|
|
852
|
+
audit = json.loads(audit_path.read_text(encoding="utf-8"))
|
|
853
|
+
audit.append({
|
|
854
|
+
"applied_at": time.time(),
|
|
855
|
+
"snapshot": str(snap.relative_to(storage.settings.storage_path)),
|
|
856
|
+
"approved_count": len(approved),
|
|
857
|
+
"replacements": affected,
|
|
858
|
+
"details": approved,
|
|
859
|
+
})
|
|
860
|
+
audit_path.write_text(json.dumps(audit, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
861
|
+
|
|
862
|
+
# Onaylanan cluster'ları proposals'tan düş — uygulananlar listede tekrar görünmesin.
|
|
863
|
+
applied_ids = {e.get("cluster_id") for e in approved if e.get("cluster_id")}
|
|
864
|
+
if applied_ids:
|
|
865
|
+
props = list_proposals(project_id, kind)
|
|
866
|
+
props["clusters"] = [c for c in props.get("clusters", []) if c.get("cluster_id") not in applied_ids]
|
|
867
|
+
(project_dir / f"disambiguation_{kind}.json").write_text(
|
|
868
|
+
json.dumps(props, ensure_ascii=False, indent=2), encoding="utf-8",
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
return {
|
|
872
|
+
"kind": kind,
|
|
873
|
+
"approved_count": len(approved),
|
|
874
|
+
"replacements": affected,
|
|
875
|
+
"snapshot": str(snap.relative_to(storage.settings.storage_path)),
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def apply_splits(project_id: str, approved: list[dict]) -> dict[str, Any]:
|
|
880
|
+
"""Onaylanan AYRIŞTIRMA önerilerini uygula — ilgili kayıtlarda yazar adına
|
|
881
|
+
'(b)' '(c)' eki ekleyerek düğümleri ayır. En büyük grup (suffix boş) dokunulmaz.
|
|
882
|
+
|
|
883
|
+
approved: split önerileri (split_id ile) veya tam obje (name + groups[]).
|
|
884
|
+
"""
|
|
885
|
+
project_dir = _project_dir(project_id)
|
|
886
|
+
src = merger.merged_dataset_path(project_id)
|
|
887
|
+
if src is None:
|
|
888
|
+
raise HTTPException(409, "no_merged_data")
|
|
889
|
+
df = pd.read_excel(src)
|
|
890
|
+
if "AU" not in df.columns:
|
|
891
|
+
raise HTTPException(400, "no_au_column")
|
|
892
|
+
|
|
893
|
+
# snapshot işin SONUNDA, yalnız affected>0 ise alınır (boş apply no-op).
|
|
894
|
+
proposals = list_proposals(project_id, "authors")
|
|
895
|
+
lookup = {s.get("split_id"): s for s in proposals.get("splits", [])}
|
|
896
|
+
|
|
897
|
+
import re as _re
|
|
898
|
+
|
|
899
|
+
def _nkey(s: str) -> str:
|
|
900
|
+
# Boşluk-normalize anahtar: WoS AU yazımı tutarsız olabilir ('GAO S' vs 'GAO S').
|
|
901
|
+
return _re.sub(r"\s+", " ", str(s or "")).strip().lower()
|
|
902
|
+
|
|
903
|
+
affected = 0
|
|
904
|
+
for entry in approved:
|
|
905
|
+
sp = lookup.get(entry.get("split_id")) or entry # tam obje de kabul
|
|
906
|
+
name = sp.get("name")
|
|
907
|
+
if not name:
|
|
908
|
+
continue
|
|
909
|
+
name_key = _nkey(name)
|
|
910
|
+
for g in sp.get("groups", []):
|
|
911
|
+
suffix = (g.get("suffix") or "").strip()
|
|
912
|
+
if not suffix:
|
|
913
|
+
continue # düz grup — dokunma
|
|
914
|
+
new_name = f"{name} {suffix}"
|
|
915
|
+
for rec in g.get("records", []):
|
|
916
|
+
if not isinstance(rec, int) or rec < 0 or rec >= len(df):
|
|
917
|
+
continue
|
|
918
|
+
cell = df.at[rec, "AU"]
|
|
919
|
+
cur = "" if pd.isna(cell) else str(cell)
|
|
920
|
+
# Boşluk-toleranslı token eşleşmesi (ham AU yazımı korunur, yalnız
|
|
921
|
+
# eşleşen token yeni adla değiştirilir).
|
|
922
|
+
new_parts, hit = [], False
|
|
923
|
+
for x in cur.split(";"):
|
|
924
|
+
if _nkey(x) == name_key:
|
|
925
|
+
new_parts.append(new_name)
|
|
926
|
+
hit = True
|
|
927
|
+
else:
|
|
928
|
+
new_parts.append(x.strip())
|
|
929
|
+
if hit:
|
|
930
|
+
df.at[rec, "AU"] = ";".join(new_parts)
|
|
931
|
+
affected += 1
|
|
932
|
+
|
|
933
|
+
if affected == 0:
|
|
934
|
+
return {"kind": "authors_split", "approved_count": len(approved), "replacements": 0, "snapshot": None}
|
|
935
|
+
|
|
936
|
+
snap = _snapshot(project_dir, pd.read_excel(src), "authors_split")
|
|
937
|
+
df.to_excel(src, index=False)
|
|
938
|
+
try:
|
|
939
|
+
from services.filter_engine import _DF_CACHE
|
|
940
|
+
_DF_CACHE.clear()
|
|
941
|
+
except Exception:
|
|
942
|
+
pass
|
|
943
|
+
|
|
944
|
+
audit_path = project_dir / "disambiguation_audit_authors_split.json"
|
|
945
|
+
log: list[dict] = []
|
|
946
|
+
if audit_path.exists():
|
|
947
|
+
log = json.loads(audit_path.read_text(encoding="utf-8"))
|
|
948
|
+
log.append({
|
|
949
|
+
"applied_at": time.time(),
|
|
950
|
+
"snapshot": str(snap.relative_to(storage.settings.storage_path)),
|
|
951
|
+
"approved_count": len(approved),
|
|
952
|
+
"replacements": affected,
|
|
953
|
+
})
|
|
954
|
+
audit_path.write_text(json.dumps(log, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
955
|
+
|
|
956
|
+
# Onaylanan split'leri proposals'tan düş — uygulananlar listede tekrar görünmesin.
|
|
957
|
+
applied_ids = {e.get("split_id") for e in approved if e.get("split_id")}
|
|
958
|
+
if applied_ids:
|
|
959
|
+
props = list_proposals(project_id, "authors")
|
|
960
|
+
props["splits"] = [s for s in props.get("splits", []) if s.get("split_id") not in applied_ids]
|
|
961
|
+
(project_dir / "disambiguation_authors.json").write_text(
|
|
962
|
+
json.dumps(props, ensure_ascii=False, indent=2), encoding="utf-8",
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
return {
|
|
966
|
+
"kind": "authors_split",
|
|
967
|
+
"approved_count": len(approved),
|
|
968
|
+
"replacements": affected,
|
|
969
|
+
"snapshot": str(snap.relative_to(storage.settings.storage_path)),
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def restore_snapshot(project_id: str, snapshot_relative: str) -> dict[str, Any]:
|
|
974
|
+
project_dir = _project_dir(project_id)
|
|
975
|
+
snap_path = storage.settings.storage_path / snapshot_relative
|
|
976
|
+
if not snap_path.exists() or project_dir not in snap_path.parents:
|
|
977
|
+
raise HTTPException(404, "snapshot_not_found")
|
|
978
|
+
target = merger.merged_dataset_path(project_id)
|
|
979
|
+
if target is None:
|
|
980
|
+
raise HTTPException(409, "no_merged_data")
|
|
981
|
+
import shutil
|
|
982
|
+
shutil.copy2(snap_path, target)
|
|
983
|
+
from services.filter_engine import _DF_CACHE
|
|
984
|
+
_DF_CACHE.clear()
|
|
985
|
+
return {"restored_from": snapshot_relative, "into": str(target.relative_to(storage.settings.storage_path))}
|