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 ADDED
@@ -0,0 +1,4 @@
1
+ """RSL.app — Servidor MCP para revisão sistemática da literatura."""
2
+
3
+ # D-001: Pacote principal em Python
4
+ __version__ = "1.0.0"
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
+ )
@@ -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