rslapp 1.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.
- rslapp/__init__.py +4 -0
- rslapp/config.py +77 -0
- rslapp/core/__init__.py +16 -0
- rslapp/core/dedup.py +319 -0
- rslapp/core/errors.py +31 -0
- rslapp/core/find.py +213 -0
- rslapp/core/models.py +177 -0
- rslapp/core/references.py +166 -0
- rslapp/core/related.py +438 -0
- rslapp/core/repeat.py +132 -0
- rslapp/core/search.py +200 -0
- rslapp/core/views.py +154 -0
- rslapp/exporters/__init__.py +165 -0
- rslapp/exporters/base.py +43 -0
- rslapp/exporters/csv.py +296 -0
- rslapp/exporters/dois.py +76 -0
- rslapp/exporters/json.py +43 -0
- rslapp/exporters/openalex_url.py +80 -0
- rslapp/exporters/ris.py +115 -0
- rslapp/prompts.py +51 -0
- rslapp/server.py +246 -0
- rslapp/sources/__init__.py +8 -0
- rslapp/sources/base.py +24 -0
- rslapp/sources/crossref.py +529 -0
- rslapp/sources/openalex.py +1054 -0
- rslapp/sources/semantic_scholar.py +774 -0
- rslapp/store/__init__.py +156 -0
- rslapp/store/_atomic.py +173 -0
- rslapp/store/cache.py +86 -0
- rslapp/store/log.py +134 -0
- rslapp-1.0.0.dist-info/METADATA +179 -0
- rslapp-1.0.0.dist-info/RECORD +35 -0
- rslapp-1.0.0.dist-info/WHEEL +4 -0
- rslapp-1.0.0.dist-info/entry_points.txt +2 -0
- rslapp-1.0.0.dist-info/licenses/LICENSE +21 -0
rslapp/__init__.py
ADDED
rslapp/config.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Configuração local do servidor RSL.app a partir de variáveis de ambiente."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
DEFAULT_DATA_DIR_NAME = "RSL.app"
|
|
10
|
+
DEFAULT_EXPORT_DIR_NAME = "exports"
|
|
11
|
+
DEFAULT_CACHE_DAYS = 7
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# FR-07: Configuração do servidor e das chaves de API na máquina do usuário via variáveis de ambiente
|
|
15
|
+
# NFR-02: Execução local (self-hosted), usando diretórios locais da máquina do usuário
|
|
16
|
+
# NFR-03: O objeto de configuração guarda apenas se as chaves estão presentes (bool), nunca o valor delas
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Config:
|
|
19
|
+
"""Configuração imutável carregada do ambiente local."""
|
|
20
|
+
|
|
21
|
+
data_dir: Path
|
|
22
|
+
export_dir: Path
|
|
23
|
+
cache_days: int = DEFAULT_CACHE_DAYS
|
|
24
|
+
contact_email: str | None = field(default=None, repr=False)
|
|
25
|
+
openalex_key_configured: bool = False
|
|
26
|
+
semantic_scholar_key_configured: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_key_configured(env_var: str) -> bool:
|
|
30
|
+
"""Verifica se uma chave de API está definida e não vazia sem reter seu valor (NFR-03)."""
|
|
31
|
+
raw = os.environ.get(env_var)
|
|
32
|
+
return bool(raw and raw.strip())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_cache_days(raw_value: str | None) -> int:
|
|
36
|
+
"""Valida e converte RSL_CACHE_DAYS para inteiro positivo."""
|
|
37
|
+
if raw_value is None:
|
|
38
|
+
return DEFAULT_CACHE_DAYS
|
|
39
|
+
try:
|
|
40
|
+
days = int(raw_value.strip())
|
|
41
|
+
except ValueError:
|
|
42
|
+
raise ValueError("RSL_CACHE_DAYS deve ser um inteiro positivo") from None
|
|
43
|
+
if days <= 0:
|
|
44
|
+
raise ValueError("RSL_CACHE_DAYS deve ser um inteiro positivo")
|
|
45
|
+
return days
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# FR-07: Leitura dinâmica da configuração a cada chamada, sem criar pastas nem reter chaves
|
|
49
|
+
def load() -> Config:
|
|
50
|
+
"""Lê as variáveis de ambiente e devolve a configuração atual."""
|
|
51
|
+
# NFR-02: Pastas locais padrão na conta do usuário
|
|
52
|
+
raw_data_dir = os.environ.get("RSL_DATA_DIR")
|
|
53
|
+
if raw_data_dir and raw_data_dir.strip():
|
|
54
|
+
data_dir = Path(raw_data_dir.strip()).expanduser()
|
|
55
|
+
else:
|
|
56
|
+
data_dir = Path.home() / DEFAULT_DATA_DIR_NAME
|
|
57
|
+
|
|
58
|
+
raw_export_dir = os.environ.get("RSL_EXPORT_DIR")
|
|
59
|
+
if raw_export_dir and raw_export_dir.strip():
|
|
60
|
+
export_dir = Path(raw_export_dir.strip()).expanduser()
|
|
61
|
+
else:
|
|
62
|
+
export_dir = data_dir / DEFAULT_EXPORT_DIR_NAME
|
|
63
|
+
|
|
64
|
+
cache_days = _parse_cache_days(os.environ.get("RSL_CACHE_DAYS"))
|
|
65
|
+
|
|
66
|
+
raw_email = os.environ.get("RSL_CONTACT_EMAIL")
|
|
67
|
+
contact_email = raw_email.strip() if raw_email and raw_email.strip() else None
|
|
68
|
+
|
|
69
|
+
# NFR-03: Apenas booleanos indicando presença das chaves são armazenados
|
|
70
|
+
return Config(
|
|
71
|
+
data_dir=data_dir,
|
|
72
|
+
export_dir=export_dir,
|
|
73
|
+
cache_days=cache_days,
|
|
74
|
+
contact_email=contact_email,
|
|
75
|
+
openalex_key_configured=_is_key_configured("OPENALEX_API_KEY"),
|
|
76
|
+
semantic_scholar_key_configured=_is_key_configured("SEMANTIC_SCHOLAR_API_KEY"),
|
|
77
|
+
)
|
rslapp/core/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Miolo (core) do RSL.app: modelos e erros independentes do protocolo MCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rslapp.core.errors import ERROR_CODES, ErrorCode, RSLError
|
|
6
|
+
from rslapp.core.models import Affiliation, Author, SourceResult, Work
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ERROR_CODES",
|
|
10
|
+
"Affiliation",
|
|
11
|
+
"Author",
|
|
12
|
+
"ErrorCode",
|
|
13
|
+
"RSLError",
|
|
14
|
+
"SourceResult",
|
|
15
|
+
"Work",
|
|
16
|
+
]
|
rslapp/core/dedup.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Deduplicação e junção de artigos acadêmicos entre múltiplas fontes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import unicodedata
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rslapp.core.models import Author, Work
|
|
11
|
+
|
|
12
|
+
# FR-04: Ordem canônica de prevalência de fontes acadêmicas
|
|
13
|
+
SOURCE_PRECEDENCE: tuple[str, ...] = ("openalex", "crossref", "semantic_scholar")
|
|
14
|
+
|
|
15
|
+
_DOI_PREFIXES: tuple[str, ...] = (
|
|
16
|
+
"https://doi.org/",
|
|
17
|
+
"http://doi.org/",
|
|
18
|
+
"https://dx.doi.org/",
|
|
19
|
+
"http://dx.doi.org/",
|
|
20
|
+
"doi:",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# FR-04: Normalização de DOI (minúsculo, sem prefixos URL ou 'doi:', sem barras finais)
|
|
25
|
+
def normalize_doi(raw: str | None) -> str | None:
|
|
26
|
+
"""Normaliza um DOI removendo prefixos conhecidos e espaços, em minúsculas."""
|
|
27
|
+
if not isinstance(raw, str):
|
|
28
|
+
return None
|
|
29
|
+
val = raw.strip().lower()
|
|
30
|
+
for prefix in _DOI_PREFIXES:
|
|
31
|
+
if val.startswith(prefix):
|
|
32
|
+
val = val[len(prefix) :].strip()
|
|
33
|
+
val = val.rstrip("/")
|
|
34
|
+
return val if val else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# FR-04: Normalização de título (minúsculas, sem acento, só letras e números, espaços simples)
|
|
38
|
+
def normalize_title(title: str) -> str:
|
|
39
|
+
"""Normaliza um título para chave de deduplicação quando não há DOI."""
|
|
40
|
+
if not isinstance(title, str):
|
|
41
|
+
return ""
|
|
42
|
+
decomposed = unicodedata.normalize("NFKD", title.lower())
|
|
43
|
+
without_accents = "".join(
|
|
44
|
+
ch for ch in decomposed if not unicodedata.combining(ch)
|
|
45
|
+
)
|
|
46
|
+
alnum_and_spaces = "".join(
|
|
47
|
+
ch for ch in without_accents if ch.isalnum() or ch.isspace()
|
|
48
|
+
)
|
|
49
|
+
return " ".join(alnum_and_spaces.split())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_work_source(work: Work) -> str | None:
|
|
53
|
+
"""Identifica o nome da fonte principal de um trabalho acadêmico."""
|
|
54
|
+
for src in SOURCE_PRECEDENCE:
|
|
55
|
+
if src in work.source_ids or src in work.citations:
|
|
56
|
+
return src
|
|
57
|
+
if work.source_ids:
|
|
58
|
+
return next(iter(work.source_ids.keys()))
|
|
59
|
+
if work.citations:
|
|
60
|
+
return next(iter(work.citations.keys()))
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _source_priority(work: Work) -> int:
|
|
65
|
+
src = get_work_source(work)
|
|
66
|
+
if src in SOURCE_PRECEDENCE:
|
|
67
|
+
return SOURCE_PRECEDENCE.index(src)
|
|
68
|
+
return len(SOURCE_PRECEDENCE)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class _WorkCluster:
|
|
73
|
+
works: list[Work] = field(default_factory=list)
|
|
74
|
+
dois: set[str] = field(default_factory=set)
|
|
75
|
+
title_years: set[tuple[str, int]] = field(default_factory=set)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class DedupResult:
|
|
80
|
+
"""Resultado da deduplicação contendo trabalhos únicos e metadados de junção."""
|
|
81
|
+
|
|
82
|
+
works: list[Work]
|
|
83
|
+
merge: dict[str, Any]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# FR-04, FR-14: Prevalência de campos na junção (OpenAlex > Crossref > Semantic Scholar)
|
|
87
|
+
def _merge_cluster_works(cluster: _WorkCluster) -> Work:
|
|
88
|
+
ordered = sorted(cluster.works, key=_source_priority)
|
|
89
|
+
|
|
90
|
+
# Campos simples: primeiro valor não vazio na ordem de precedência
|
|
91
|
+
title = next((w.title for w in ordered if w.title and w.title.strip()), "")
|
|
92
|
+
doi = next((normalize_doi(w.doi) for w in ordered if normalize_doi(w.doi)), None)
|
|
93
|
+
year = next((w.year for w in ordered if isinstance(w.year, int)), None)
|
|
94
|
+
type_ = next((w.type for w in ordered if w.type and w.type.strip()), None)
|
|
95
|
+
venue = next((w.venue for w in ordered if w.venue and w.venue.strip()), None)
|
|
96
|
+
abstract = next((w.abstract for w in ordered if w.abstract and w.abstract.strip()), None)
|
|
97
|
+
references_count = next(
|
|
98
|
+
(w.references_count for w in ordered if isinstance(w.references_count, int)), None
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# authors e keywords: primeira fonte que os tiver não vazios
|
|
102
|
+
authors_work = next((w for w in ordered if w.authors), None)
|
|
103
|
+
authors = [Author.from_dict(a.to_dict()) for a in authors_work.authors] if authors_work else []
|
|
104
|
+
|
|
105
|
+
keywords_work = next((w for w in ordered if w.keywords), None)
|
|
106
|
+
keywords = list(keywords_work.keywords) if keywords_work else []
|
|
107
|
+
|
|
108
|
+
# source_ids e citations: unem todas as fontes sem perda e sem somar citações (FR-14)
|
|
109
|
+
source_ids: dict[str, str] = {}
|
|
110
|
+
for w in ordered:
|
|
111
|
+
for src_name, src_id in w.source_ids.items():
|
|
112
|
+
if src_name not in source_ids:
|
|
113
|
+
source_ids[src_name] = src_id
|
|
114
|
+
|
|
115
|
+
citations: dict[str, int] = {}
|
|
116
|
+
for w in ordered:
|
|
117
|
+
for src_name, count in w.citations.items():
|
|
118
|
+
if src_name not in citations:
|
|
119
|
+
citations[src_name] = count
|
|
120
|
+
|
|
121
|
+
# references: união na ordem sem repetição, com ids da primeira fonte primeiro
|
|
122
|
+
references: list[str] = []
|
|
123
|
+
seen_refs: set[str] = set()
|
|
124
|
+
for w in ordered:
|
|
125
|
+
for ref in w.references:
|
|
126
|
+
if ref not in seen_refs:
|
|
127
|
+
seen_refs.add(ref)
|
|
128
|
+
references.append(ref)
|
|
129
|
+
|
|
130
|
+
return Work(
|
|
131
|
+
title=title,
|
|
132
|
+
doi=doi,
|
|
133
|
+
year=year,
|
|
134
|
+
type=type_,
|
|
135
|
+
venue=venue,
|
|
136
|
+
abstract=abstract,
|
|
137
|
+
keywords=keywords,
|
|
138
|
+
authors=authors,
|
|
139
|
+
references=references,
|
|
140
|
+
references_count=references_count,
|
|
141
|
+
citations=citations,
|
|
142
|
+
source_ids=source_ids,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# FR-04: Contagem de divergências por campo em artigos presentes em mais de uma fonte
|
|
147
|
+
def _compute_cluster_divergences(cluster: _WorkCluster) -> dict[str, bool]:
|
|
148
|
+
diverged = {"year": False, "title": False, "doi": False, "venue": False}
|
|
149
|
+
ordered = sorted(cluster.works, key=_source_priority)
|
|
150
|
+
|
|
151
|
+
# Agrupa o primeiro valor de cada fonte distinta
|
|
152
|
+
seen_sources: set[str] = set()
|
|
153
|
+
titles: list[str] = []
|
|
154
|
+
years: list[int] = []
|
|
155
|
+
dois: list[str] = []
|
|
156
|
+
venues: list[str] = []
|
|
157
|
+
|
|
158
|
+
for w in ordered:
|
|
159
|
+
src = get_work_source(w) or id(w)
|
|
160
|
+
if src in seen_sources:
|
|
161
|
+
continue
|
|
162
|
+
seen_sources.add(str(src))
|
|
163
|
+
|
|
164
|
+
t_norm = normalize_title(w.title)
|
|
165
|
+
if t_norm:
|
|
166
|
+
titles.append(t_norm)
|
|
167
|
+
|
|
168
|
+
if isinstance(w.year, int):
|
|
169
|
+
years.append(w.year)
|
|
170
|
+
|
|
171
|
+
d_norm = normalize_doi(w.doi)
|
|
172
|
+
if d_norm:
|
|
173
|
+
dois.append(d_norm)
|
|
174
|
+
|
|
175
|
+
if w.venue and w.venue.strip():
|
|
176
|
+
venues.append(w.venue.strip())
|
|
177
|
+
|
|
178
|
+
if len(set(titles)) > 1:
|
|
179
|
+
diverged["title"] = True
|
|
180
|
+
if len(set(years)) > 1:
|
|
181
|
+
diverged["year"] = True
|
|
182
|
+
if len(set(dois)) > 1:
|
|
183
|
+
diverged["doi"] = True
|
|
184
|
+
if len(set(venues)) > 1:
|
|
185
|
+
diverged["venue"] = True
|
|
186
|
+
|
|
187
|
+
return diverged
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# FR-04: Atualização de índices do grupo para junção transitiva
|
|
191
|
+
def _reindex_cluster(
|
|
192
|
+
cluster: _WorkCluster,
|
|
193
|
+
by_doi: dict[str, _WorkCluster],
|
|
194
|
+
by_title_year: dict[tuple[str, int], list[_WorkCluster]],
|
|
195
|
+
) -> None:
|
|
196
|
+
for w in cluster.works:
|
|
197
|
+
norm_d = normalize_doi(w.doi)
|
|
198
|
+
if norm_d is not None:
|
|
199
|
+
cluster.dois.add(norm_d)
|
|
200
|
+
by_doi[norm_d] = cluster
|
|
201
|
+
|
|
202
|
+
# FR-04: só o título com o ano do próprio registro entra no índice; ano ausente
|
|
203
|
+
# nunca é emprestado de outro membro (a regra é por par, e assim não depende da ordem)
|
|
204
|
+
norm_t = normalize_title(w.title)
|
|
205
|
+
if norm_t and isinstance(w.year, int):
|
|
206
|
+
ty = (norm_t, w.year)
|
|
207
|
+
cluster.title_years.add(ty)
|
|
208
|
+
cands = by_title_year.setdefault(ty, [])
|
|
209
|
+
if cluster not in cands:
|
|
210
|
+
cands.append(cluster)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# FR-04: Fusão de grupo de origem no alvo com migração de referências e índices
|
|
214
|
+
def _merge_clusters(
|
|
215
|
+
target: _WorkCluster,
|
|
216
|
+
source: _WorkCluster,
|
|
217
|
+
clusters: list[_WorkCluster],
|
|
218
|
+
by_doi: dict[str, _WorkCluster],
|
|
219
|
+
by_title_year: dict[tuple[str, int], list[_WorkCluster]],
|
|
220
|
+
) -> None:
|
|
221
|
+
target.works.extend(source.works)
|
|
222
|
+
if source in clusters:
|
|
223
|
+
clusters.remove(source)
|
|
224
|
+
for ty in source.title_years:
|
|
225
|
+
if ty in by_title_year and source in by_title_year[ty]:
|
|
226
|
+
by_title_year[ty].remove(source)
|
|
227
|
+
for d in source.dois:
|
|
228
|
+
if by_doi.get(d) is source:
|
|
229
|
+
by_doi[d] = target
|
|
230
|
+
_reindex_cluster(target, by_doi, by_title_year)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# FR-04, FR-14: Deduplicação transitiva com contagem de artigos juntados e divergências
|
|
234
|
+
def deduplicate_works_with_merge(works: Sequence[Work]) -> DedupResult:
|
|
235
|
+
"""Agrupa artigos duplicados por DOI ou (título normalizado + ano), gerando merge info."""
|
|
236
|
+
clusters: list[_WorkCluster] = []
|
|
237
|
+
by_doi: dict[str, _WorkCluster] = {}
|
|
238
|
+
by_title_year: dict[tuple[str, int], list[_WorkCluster]] = {}
|
|
239
|
+
|
|
240
|
+
for work in works:
|
|
241
|
+
norm_doi = normalize_doi(work.doi)
|
|
242
|
+
norm_title = normalize_title(work.title)
|
|
243
|
+
year = work.year if isinstance(work.year, int) else None
|
|
244
|
+
|
|
245
|
+
doi_cluster = by_doi.get(norm_doi) if norm_doi is not None else None
|
|
246
|
+
|
|
247
|
+
title_clusters: list[_WorkCluster] = []
|
|
248
|
+
if norm_title and year is not None:
|
|
249
|
+
for cand in by_title_year.get((norm_title, year), []):
|
|
250
|
+
if cand not in title_clusters:
|
|
251
|
+
title_clusters.append(cand)
|
|
252
|
+
|
|
253
|
+
# Escolha do cluster alvo respeitando regras de DOI e junção transitiva:
|
|
254
|
+
# - Dois DOIs diferentes nunca se juntam (FR-04).
|
|
255
|
+
# - Se um registro casa com dois grupos diferentes (um pelo DOI, outro pelo título),
|
|
256
|
+
# ele fica no grupo do DOI (FR-04).
|
|
257
|
+
target: _WorkCluster
|
|
258
|
+
if doi_cluster is not None:
|
|
259
|
+
target = doi_cluster
|
|
260
|
+
elif norm_doi is not None:
|
|
261
|
+
valid_cands = [c for c in title_clusters if not c.dois]
|
|
262
|
+
if valid_cands:
|
|
263
|
+
target = valid_cands[0]
|
|
264
|
+
else:
|
|
265
|
+
target = _WorkCluster()
|
|
266
|
+
clusters.append(target)
|
|
267
|
+
else:
|
|
268
|
+
if title_clusters:
|
|
269
|
+
doi_cands = [c for c in title_clusters if c.dois]
|
|
270
|
+
target = doi_cands[0] if doi_cands else title_clusters[0]
|
|
271
|
+
else:
|
|
272
|
+
target = _WorkCluster()
|
|
273
|
+
clusters.append(target)
|
|
274
|
+
|
|
275
|
+
# Funde transitivamente quaisquer candidatos por título que não tenham DOI conflitante
|
|
276
|
+
for cand in title_clusters:
|
|
277
|
+
if cand is not target and not cand.dois:
|
|
278
|
+
_merge_clusters(target, cand, clusters, by_doi, by_title_year)
|
|
279
|
+
|
|
280
|
+
target.works.append(work)
|
|
281
|
+
_reindex_cluster(target, by_doi, by_title_year)
|
|
282
|
+
|
|
283
|
+
merged_count = 0
|
|
284
|
+
divergence_counts: dict[str, int] = {"year": 0, "title": 0, "doi": 0, "venue": 0}
|
|
285
|
+
deduped_works: list[Work] = []
|
|
286
|
+
|
|
287
|
+
for cluster in clusters:
|
|
288
|
+
distinct_sources: set[str] = set()
|
|
289
|
+
for w in cluster.works:
|
|
290
|
+
src = get_work_source(w)
|
|
291
|
+
if src:
|
|
292
|
+
distinct_sources.add(src)
|
|
293
|
+
|
|
294
|
+
is_multi_source = len(distinct_sources) > 1 or (
|
|
295
|
+
not distinct_sources and len(cluster.works) > 1
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if is_multi_source:
|
|
299
|
+
merged_count += 1
|
|
300
|
+
divs = _compute_cluster_divergences(cluster)
|
|
301
|
+
for field_name, had_div in divs.items():
|
|
302
|
+
if had_div:
|
|
303
|
+
divergence_counts[field_name] += 1
|
|
304
|
+
|
|
305
|
+
deduped_works.append(_merge_cluster_works(cluster))
|
|
306
|
+
|
|
307
|
+
divergences_result = {k: v for k, v in divergence_counts.items() if v > 0}
|
|
308
|
+
merge_info: dict[str, Any] = {
|
|
309
|
+
"merged": merged_count,
|
|
310
|
+
"divergences": divergences_result,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return DedupResult(works=deduped_works, merge=merge_info)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# FR-04: Interface simplificada de deduplicação preservando compatibilidade
|
|
317
|
+
def deduplicate_works(works: Sequence[Work]) -> list[Work]:
|
|
318
|
+
"""Remove duplicatas mantendo o primeiro registro pela ordem de precedência e agregando dados."""
|
|
319
|
+
return deduplicate_works_with_merge(works).works
|
rslapp/core/errors.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Erros do miolo do RSL.app segundo o contrato de interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal, get_args
|
|
6
|
+
|
|
7
|
+
# FR-06, D-002: Códigos de erro do contrato de interface (incluindo empty_list para lista sem artigos)
|
|
8
|
+
ErrorCode = Literal[
|
|
9
|
+
"no_source_available",
|
|
10
|
+
"invalid_input",
|
|
11
|
+
"list_not_found",
|
|
12
|
+
"search_not_found",
|
|
13
|
+
"work_not_found",
|
|
14
|
+
"format_not_available",
|
|
15
|
+
"export_folder_not_writable",
|
|
16
|
+
"empty_list",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
ERROR_CODES: frozenset[str] = frozenset(get_args(ErrorCode))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# D-002: Códigos de erro estáveis do contrato, sem dependência do protocolo MCP no miolo
|
|
23
|
+
class RSLError(Exception):
|
|
24
|
+
"""Exceção de domínio do miolo que o servidor MCP converte na borda."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, code: ErrorCode | str, message: str) -> None:
|
|
27
|
+
if code not in ERROR_CODES:
|
|
28
|
+
raise ValueError(f"Código de erro fora do contrato: {code!r}")
|
|
29
|
+
self.code: str = code
|
|
30
|
+
self.message: str = message
|
|
31
|
+
super().__init__(f"{code}: {message}")
|
rslapp/core/find.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Miolo de busca de artigo específico por título ou DOI, deduplicação e gravação."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rslapp.core.dedup import deduplicate_works_with_merge, normalize_doi
|
|
10
|
+
from rslapp.core.errors import RSLError
|
|
11
|
+
from rslapp.core.models import SourceResult, Work
|
|
12
|
+
from rslapp.core.search import _select_sources, _summarize_work
|
|
13
|
+
from rslapp.sources.base import Source
|
|
14
|
+
from rslapp.store import Store
|
|
15
|
+
|
|
16
|
+
PAGE_SIZE = 25
|
|
17
|
+
DEFAULT_MAX_RESULTS = 10
|
|
18
|
+
MIN_MAX_RESULTS = 1
|
|
19
|
+
MAX_MAX_RESULTS = 100
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DEFAULT_MAX_RESULTS",
|
|
23
|
+
"MAX_MAX_RESULTS",
|
|
24
|
+
"MIN_MAX_RESULTS",
|
|
25
|
+
"PAGE_SIZE",
|
|
26
|
+
"run_find",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# FR-02, D-002: Validação estrita de DOI (começa com 10., sem espaço, ?, #, % nem segmentos . ou ..)
|
|
31
|
+
def _validate_and_normalize_doi(raw: Any) -> str:
|
|
32
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
33
|
+
raise RSLError("invalid_input", "doi não pode ser vazio")
|
|
34
|
+
val = raw.strip()
|
|
35
|
+
norm = normalize_doi(val)
|
|
36
|
+
if not norm or not norm.startswith("10."):
|
|
37
|
+
raise RSLError("invalid_input", f"doi deve começar com '10.': {raw}")
|
|
38
|
+
if any(ch.isspace() for ch in norm):
|
|
39
|
+
raise RSLError("invalid_input", f"doi não pode conter espaços: {raw}")
|
|
40
|
+
if any(ch in "?#%" for ch in norm):
|
|
41
|
+
raise RSLError("invalid_input", f"doi contém caracteres inválidos: {raw}")
|
|
42
|
+
segments = norm.split("/")
|
|
43
|
+
if any(seg in (".", "..") for seg in segments):
|
|
44
|
+
raise RSLError("invalid_input", f"doi contém segmentos inválidos: {raw}")
|
|
45
|
+
return norm
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# FR-02, UC-02, D-002: Orquestração da busca de artigo específico por DOI ou título
|
|
49
|
+
async def run_find(
|
|
50
|
+
store: Store,
|
|
51
|
+
sources: Sequence[Source],
|
|
52
|
+
*,
|
|
53
|
+
title: str | None = None,
|
|
54
|
+
doi: str | None = None,
|
|
55
|
+
request_summary: str = "",
|
|
56
|
+
max_results: int = DEFAULT_MAX_RESULTS,
|
|
57
|
+
source_names: Sequence[str] | None = None,
|
|
58
|
+
repeated_from: str | None = None,
|
|
59
|
+
kind: str = "work",
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""Busca um artigo específico por título ou DOI nas fontes, deduplica e grava lista própria."""
|
|
62
|
+
if (title is None and doi is None) or (title is not None and doi is not None):
|
|
63
|
+
raise RSLError("invalid_input", "informe exatamente um entre title ou doi")
|
|
64
|
+
|
|
65
|
+
clean_doi: str | None = None
|
|
66
|
+
clean_title: str | None = None
|
|
67
|
+
|
|
68
|
+
if doi is not None:
|
|
69
|
+
clean_doi = _validate_and_normalize_doi(doi)
|
|
70
|
+
else:
|
|
71
|
+
assert title is not None
|
|
72
|
+
if not isinstance(title, str) or not title.strip():
|
|
73
|
+
raise RSLError("invalid_input", "title não pode ser vazio")
|
|
74
|
+
clean_title = title.strip()
|
|
75
|
+
|
|
76
|
+
if not isinstance(request_summary, str):
|
|
77
|
+
raise RSLError("invalid_input", "request_summary deve ser texto")
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
not isinstance(max_results, int)
|
|
81
|
+
or isinstance(max_results, bool)
|
|
82
|
+
or max_results < MIN_MAX_RESULTS
|
|
83
|
+
or max_results > MAX_MAX_RESULTS
|
|
84
|
+
):
|
|
85
|
+
raise RSLError(
|
|
86
|
+
"invalid_input",
|
|
87
|
+
f"max_results deve ser um inteiro entre {MIN_MAX_RESULTS} e {MAX_MAX_RESULTS}",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
selected_sources = _select_sources(sources, source_names)
|
|
91
|
+
|
|
92
|
+
# FR-02: Execução por DOI via find_by_doi ou por título via search
|
|
93
|
+
if clean_doi is not None:
|
|
94
|
+
async def _fetch_by_doi(source: Source) -> tuple[SourceResult, bool, str | None]:
|
|
95
|
+
if not hasattr(source, "find_by_doi"):
|
|
96
|
+
return (
|
|
97
|
+
SourceResult(
|
|
98
|
+
name=source.name,
|
|
99
|
+
status="not_supported",
|
|
100
|
+
reason=f"não suportado por {source.name}",
|
|
101
|
+
query=f"doi:{clean_doi}",
|
|
102
|
+
found=None,
|
|
103
|
+
fetched=0,
|
|
104
|
+
works=[],
|
|
105
|
+
),
|
|
106
|
+
False,
|
|
107
|
+
None,
|
|
108
|
+
)
|
|
109
|
+
result = await source.find_by_doi(clean_doi)
|
|
110
|
+
return result, False, None
|
|
111
|
+
|
|
112
|
+
fetch_outcomes = await asyncio.gather(
|
|
113
|
+
*[_fetch_by_doi(src) for src in selected_sources]
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
assert clean_title is not None
|
|
117
|
+
|
|
118
|
+
# NFR-06: Consulta o cache antes de chamar cada fonte e só grava no cache resultados ok
|
|
119
|
+
async def _fetch_by_title(source: Source) -> tuple[SourceResult, bool, str | None]:
|
|
120
|
+
c_query = source.cache_query(clean_title, max_results)
|
|
121
|
+
cached = store.get_cache(source.name, c_query)
|
|
122
|
+
if cached is not None:
|
|
123
|
+
return SourceResult.from_dict(cached["response"]), True, cached.get("fetched_at")
|
|
124
|
+
result = await source.search(clean_title, max_results)
|
|
125
|
+
if result.status == "ok":
|
|
126
|
+
store.set_cache(source.name, c_query, result.to_dict())
|
|
127
|
+
return result, False, None
|
|
128
|
+
|
|
129
|
+
fetch_outcomes = await asyncio.gather(
|
|
130
|
+
*[_fetch_by_title(src) for src in selected_sources]
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
source_entries: list[dict[str, Any]] = []
|
|
134
|
+
raw_works: list[Work] = []
|
|
135
|
+
|
|
136
|
+
for result, from_cache, fetched_at in fetch_outcomes:
|
|
137
|
+
raw_works.extend(result.works)
|
|
138
|
+
source_entries.append(
|
|
139
|
+
{
|
|
140
|
+
"name": result.name,
|
|
141
|
+
"query": result.query,
|
|
142
|
+
"status": result.status,
|
|
143
|
+
"reason": result.reason,
|
|
144
|
+
"found": result.found,
|
|
145
|
+
"fetched": result.fetched,
|
|
146
|
+
"from_cache": from_cache,
|
|
147
|
+
"fetched_at": fetched_at,
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# D-002: Erro no_source_available quando todas as fontes estão sem chave
|
|
152
|
+
if all(entry["status"] == "missing_key" for entry in source_entries):
|
|
153
|
+
raise RSLError(
|
|
154
|
+
"no_source_available",
|
|
155
|
+
"nenhuma fonte pode responder; configure OPENALEX_API_KEY",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# FR-04, FR-14: Deduplicação e junção entre fontes com prevalência e contagem de divergências
|
|
159
|
+
dedup_result = deduplicate_works_with_merge(raw_works)
|
|
160
|
+
deduped_works = dedup_result.works
|
|
161
|
+
total = len(deduped_works)
|
|
162
|
+
|
|
163
|
+
# FR-08: Lista incompleta quando alguma fonte consultada não terminou em ok
|
|
164
|
+
list_status = (
|
|
165
|
+
"complete"
|
|
166
|
+
if all(entry["status"] == "ok" for entry in source_entries)
|
|
167
|
+
else "incomplete"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
params: dict[str, Any] = {
|
|
171
|
+
"max_results": max_results,
|
|
172
|
+
"sources": [src.name for src in selected_sources],
|
|
173
|
+
}
|
|
174
|
+
if clean_doi is not None:
|
|
175
|
+
params["doi"] = clean_doi
|
|
176
|
+
else:
|
|
177
|
+
assert clean_title is not None
|
|
178
|
+
params["title"] = clean_title
|
|
179
|
+
|
|
180
|
+
# FR-02, FR-05, FR-09, FR-12: Gravação da busca e lista (vazia ou preenchida)
|
|
181
|
+
search_data: dict[str, Any] = {
|
|
182
|
+
"kind": kind,
|
|
183
|
+
"request_summary": request_summary,
|
|
184
|
+
"repeated_from": repeated_from,
|
|
185
|
+
"origin": None,
|
|
186
|
+
"params": params,
|
|
187
|
+
"merge": dedup_result.merge,
|
|
188
|
+
"sources": source_entries,
|
|
189
|
+
}
|
|
190
|
+
list_data: dict[str, Any] = {
|
|
191
|
+
"status": list_status,
|
|
192
|
+
"total": total,
|
|
193
|
+
"works": [work.to_dict() for work in deduped_works],
|
|
194
|
+
}
|
|
195
|
+
saved_search, saved_list = store.record_search(search_data, list_data)
|
|
196
|
+
|
|
197
|
+
first_page_works = [_summarize_work(w) for w in deduped_works[:PAGE_SIZE]]
|
|
198
|
+
next_page: int | None = 2 if total > PAGE_SIZE else None
|
|
199
|
+
|
|
200
|
+
result_dict: dict[str, Any] = {
|
|
201
|
+
"search_id": saved_search["id"],
|
|
202
|
+
"list_id": saved_list["id"],
|
|
203
|
+
"status": list_status,
|
|
204
|
+
"total": total,
|
|
205
|
+
"page": 1,
|
|
206
|
+
"next_page": next_page,
|
|
207
|
+
"sources": saved_search["sources"],
|
|
208
|
+
"works": first_page_works,
|
|
209
|
+
}
|
|
210
|
+
if repeated_from is not None:
|
|
211
|
+
result_dict["repeated_from"] = repeated_from
|
|
212
|
+
|
|
213
|
+
return result_dict
|