swarm-notes 0.3.4__tar.gz → 0.3.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/PKG-INFO +12 -1
  2. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/README.md +11 -0
  3. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/pyproject.toml +1 -1
  4. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/analyst.py +52 -18
  5. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/config.py +12 -0
  6. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/critic.py +37 -11
  7. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/discussant.py +2 -2
  8. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/domain_expert.py +2 -2
  9. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/vault_manager.py +107 -30
  10. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/__init__.py +0 -0
  11. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/federation.py +0 -0
  12. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/main.py +0 -0
  13. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/__init__.py +0 -0
  14. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/arxiv.py +0 -0
  15. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/base.py +0 -0
  16. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/biorxiv.py +0 -0
  17. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/common.py +0 -0
  18. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/openalex.py +0 -0
  19. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/semantic_scholar.py +0 -0
  20. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/pdf_converter.py +0 -0
  21. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/router.py +0 -0
  22. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/vault_writer.py +0 -0
  23. {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/watcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: swarm-notes
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: Automated research paper tracking and knowledge synthesis
5
5
  Author: LM
6
6
  Author-email: LM <hi@leima.is>
@@ -140,6 +140,17 @@ for biorxiv/medrxiv papers.
140
140
  For CI/CD, add `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` as repository secrets
141
141
  (same place as `LLM_API_KEY`).
142
142
 
143
+ ### Fallback model
144
+
145
+ Set `llm_fallback_model` in the config (or the `LLM_FALLBACK_MODEL` environment variable) to a second model. Every agent call tries `llm_model` first and switches to the fallback when that call fails, for example on a rate limit, a 4xx/5xx error or an outage:
146
+
147
+ ```yaml
148
+ llm_model: "google-gla:gemini-flash-lite-latest"
149
+ llm_fallback_model: "anthropic:claude-haiku-5-5"
150
+ ```
151
+
152
+ The fallback provider needs its own key, for example `ANTHROPIC_API_KEY` for Claude.
153
+
143
154
  ## CI/CD Setup
144
155
 
145
156
  ### Add the required secret
@@ -121,6 +121,17 @@ for biorxiv/medrxiv papers.
121
121
  For CI/CD, add `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` as repository secrets
122
122
  (same place as `LLM_API_KEY`).
123
123
 
124
+ ### Fallback model
125
+
126
+ Set `llm_fallback_model` in the config (or the `LLM_FALLBACK_MODEL` environment variable) to a second model. Every agent call tries `llm_model` first and switches to the fallback when that call fails, for example on a rate limit, a 4xx/5xx error or an outage:
127
+
128
+ ```yaml
129
+ llm_model: "google-gla:gemini-flash-lite-latest"
130
+ llm_fallback_model: "anthropic:claude-haiku-5-5"
131
+ ```
132
+
133
+ The fallback provider needs its own key, for example `ANTHROPIC_API_KEY` for Claude.
134
+
124
135
  ## CI/CD Setup
125
136
 
126
137
  ### Add the required secret
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "swarm-notes"
3
- version = "0.3.4"
3
+ version = "0.3.6"
4
4
  description = "Automated research paper tracking and knowledge synthesis"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -8,14 +8,16 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import logging
11
+ import re
12
+ from collections import Counter
11
13
  from typing import Annotated, Literal
12
14
 
13
15
  from pydantic import BaseModel, Field
14
16
  from pydantic_ai import Agent
15
17
 
16
- from swarm_notes.config import settings
18
+ from swarm_notes.config import get_model, settings
17
19
  from swarm_notes.router import SkillSpec
18
- from swarm_notes.vault_manager import get_existing_tags
20
+ from swarm_notes.vault_manager import get_tag_counts, select_relevant
19
21
  from swarm_notes.watcher import RawPaper
20
22
 
21
23
  logger = logging.getLogger(__name__)
@@ -222,8 +224,10 @@ class PaperAnalysis(PaperAnalysisBase):
222
224
  # Upper bound on how many existing tags are listed in the prompt. Tags are
223
225
  # passed as prompt text rather than as an enum in the output schema: a schema
224
226
  # enum with hundreds of values makes Gemini reject the request with
225
- # 400 INVALID_ARGUMENT.
226
- MAX_PROMPT_TAGS = 1000
227
+ # 400 INVALID_ARGUMENT. The tags listed are the ones most relevant to the
228
+ # paper (see ``select_relevant``), so the prompt stays the same size as the
229
+ # vault grows.
230
+ MAX_PROMPT_TAGS = 200
227
231
 
228
232
 
229
233
  # ---------------------------------------------------------------------------
@@ -291,11 +295,21 @@ def analyse(paper: RawPaper, skill: SkillSpec) -> PaperAnalysis:
291
295
  Structured extraction result.
292
296
  """
293
297
  taxonomy = _load_taxonomy()
294
- existing_tags = get_existing_tags()
295
- system_prompt = _build_system_prompt(skill, taxonomy, existing_tags)
298
+ tag_counts = get_tag_counts()
299
+ existing_tags = sorted(tag_counts)
300
+ # List one spelling per tag (the most used), so variants such as
301
+ # "time-series" and "time series" don't take up several slots.
302
+ canonical_tags = sorted(set(_canonical_tags(existing_tags, tag_counts).values()))
303
+ prompt_tags = select_relevant(
304
+ canonical_tags,
305
+ f"{paper.title}\n{paper.abstract}",
306
+ MAX_PROMPT_TAGS,
307
+ tag_counts,
308
+ )
309
+ system_prompt = _build_system_prompt(skill, taxonomy, prompt_tags)
296
310
 
297
311
  agent: Agent[None, PaperAnalysis] = Agent(
298
- model=settings.llm_model,
312
+ model=get_model(),
299
313
  output_type=PaperAnalysis,
300
314
  system_prompt=system_prompt,
301
315
  )
@@ -313,7 +327,7 @@ def analyse(paper: RawPaper, skill: SkillSpec) -> PaperAnalysis:
313
327
 
314
328
  result = agent.run_sync(user_message)
315
329
  analysis: PaperAnalysis = result.output
316
- analysis.tags = _normalise_tags(analysis.tags, existing_tags)
330
+ analysis.tags = _normalise_tags(analysis.tags, existing_tags, tag_counts)
317
331
 
318
332
  # Ensure metadata from the raw paper is preserved accurately
319
333
  analysis.arxiv_id = paper.arxiv_id
@@ -332,19 +346,39 @@ def analyse(paper: RawPaper, skill: SkillSpec) -> PaperAnalysis:
332
346
  return analysis
333
347
 
334
348
 
335
- def _normalise_tags(tags: list[str], existing_tags: list[str]) -> list[str]:
336
- """Map tags onto the vault's existing spelling (case-insensitive) and drop duplicates."""
337
- canonical = {t.lower(): t for t in existing_tags}
349
+ def _tag_key(tag: str) -> str:
350
+ """Key that treats case, spaces, hyphens and underscores as the same."""
351
+ return re.sub(r"[\s_\-]+", " ", tag.strip().lower()).strip()
352
+
353
+
354
+ def _canonical_tags(existing_tags: list[str], counts: Counter[str] | None = None) -> dict[str, str]:
355
+ """Map each tag key to the vault's most used spelling of it."""
356
+ counts = counts or Counter()
357
+ canonical: dict[str, str] = {}
358
+ for t in sorted(existing_tags, key=lambda t: (-counts.get(t, 0), t)):
359
+ canonical.setdefault(_tag_key(t), t)
360
+ return canonical
361
+
362
+
363
+ def _normalise_tags(
364
+ tags: list[str],
365
+ existing_tags: list[str],
366
+ counts: Counter[str] | None = None,
367
+ ) -> list[str]:
368
+ """Map tags onto the vault's existing spelling and drop duplicates.
369
+
370
+ "Time-Series", "time series" and "time_series" all map to whichever
371
+ spelling the vault already uses most.
372
+ """
373
+ canonical = _canonical_tags(existing_tags, counts)
338
374
  result: list[str] = []
339
375
  seen: set[str] = set()
340
- for tag in tags:
341
- tag = tag.strip()
342
- if not tag:
376
+ for tag in (part.strip() for t in tags for part in t.split(",")):
377
+ key = _tag_key(tag)
378
+ if not key or key in seen:
343
379
  continue
344
- tag = canonical.get(tag.lower(), tag)
345
- if tag.lower() not in seen:
346
- seen.add(tag.lower())
347
- result.append(tag)
380
+ seen.add(key)
381
+ result.append(canonical.get(key, tag))
348
382
  return result
349
383
 
350
384
 
@@ -153,6 +153,9 @@ class Settings(BaseModel):
153
153
  openai_api_key: str = Field(default_factory=lambda: os.environ.get("OPENAI_API_KEY", os.environ.get("LLM_API_KEY", "")))
154
154
  gemini_api_key: str = Field(default_factory=lambda: os.environ.get("GEMINI_API_KEY", os.environ.get("GOOGLE_API_KEY", "")))
155
155
  llm_model: str = Field(default="openai:gpt-4o-mini")
156
+ # Optional second model, tried whenever a call to llm_model fails (rate
157
+ # limit, 4xx/5xx, outage). Empty disables the fallback.
158
+ llm_fallback_model: str = Field(default_factory=lambda: os.environ.get("LLM_FALLBACK_MODEL", ""))
156
159
 
157
160
  # Watcher config
158
161
  paper_search: PaperSearchSettings = Field(default_factory=PaperSearchSettings)
@@ -334,6 +337,15 @@ class Settings(BaseModel):
334
337
 
335
338
  settings = Settings.load_from_yaml()
336
339
 
340
+ def get_model() -> Any:
341
+ """Return the model for agents: llm_model, falling back to llm_fallback_model on errors."""
342
+ if not settings.llm_fallback_model:
343
+ return settings.llm_model
344
+ from pydantic_ai.models.fallback import FallbackModel
345
+
346
+ return FallbackModel(settings.llm_model, settings.llm_fallback_model)
347
+
348
+
337
349
  def load_yaml_config(yaml_path: str | Path) -> None:
338
350
  """Override configuration variables from a YAML file."""
339
351
  global settings
@@ -10,9 +10,9 @@ from pydantic import BaseModel, Field
10
10
  from pydantic_ai import Agent
11
11
 
12
12
  from swarm_notes.analyst import ConceptLink, DatasetLink, OpenQuestion, PaperAnalysis, RejectedCandidate
13
- from swarm_notes.config import settings
13
+ from swarm_notes.config import get_model, settings
14
14
  from swarm_notes.router import SkillSpec
15
- from swarm_notes.vault_manager import get_existing_entities
15
+ from swarm_notes.vault_manager import get_existing_entities, select_relevant
16
16
  from swarm_notes.watcher import RawPaper
17
17
 
18
18
  logger = logging.getLogger(__name__)
@@ -61,13 +61,29 @@ class CriticReview(BaseModel):
61
61
  )
62
62
 
63
63
 
64
- def _build_system_prompt(skill: SkillSpec) -> str:
65
- existing_concepts = get_existing_entities("concepts")
66
- existing_concepts_str = ", ".join(existing_concepts) if existing_concepts else "(No concepts currently exist in the vault)"
67
- existing_open_questions = get_existing_entities("open_questions")
68
- existing_open_questions_str = ", ".join(existing_open_questions) if existing_open_questions else "(No open questions currently exist in the vault)"
69
- existing_datasets = get_existing_entities("datasets")
70
- existing_datasets_str = ", ".join(existing_datasets) if existing_datasets else "(No datasets currently exist in the vault)"
64
+ # Upper bound on how many existing concepts, open questions and datasets are
65
+ # listed in the critic prompt (per type). The vault holds thousands of them, so
66
+ # only the ones most relevant to the paper are shown.
67
+ MAX_PROMPT_ENTITIES = 150
68
+
69
+
70
+ def _existing_entities_str(entity_type: str, context: str | None, empty: str) -> str:
71
+ names = get_existing_entities(entity_type)
72
+ if context is not None:
73
+ names = select_relevant(names, context, MAX_PROMPT_ENTITIES)
74
+ return ", ".join(names) if names else empty
75
+
76
+
77
+ def _build_system_prompt(skill: SkillSpec, context: str | None = None) -> str:
78
+ existing_concepts_str = _existing_entities_str(
79
+ "concepts", context, "(No concepts currently exist in the vault)"
80
+ )
81
+ existing_open_questions_str = _existing_entities_str(
82
+ "open_questions", context, "(No open questions currently exist in the vault)"
83
+ )
84
+ existing_datasets_str = _existing_entities_str(
85
+ "datasets", context, "(No datasets currently exist in the vault)"
86
+ )
71
87
 
72
88
  prompt = f"""You are a highly selective research archivist protecting a long-lived ML knowledge vault.
73
89
 
@@ -115,6 +131,16 @@ REVIEW POLICY:
115
131
  return prompt
116
132
 
117
133
 
134
+ def _relevance_context(analysis: PaperAnalysis, paper: RawPaper) -> str:
135
+ """Text used to pick which existing vault entities the critic sees."""
136
+ candidates = [
137
+ *(f"{c.slug} {c.one_liner}" for c in analysis.concepts),
138
+ *(f"{q.slug} {q.description}" for q in analysis.open_questions),
139
+ *(f"{d.name} {d.description}" for d in analysis.datasets),
140
+ ]
141
+ return "\n".join([paper.title, paper.abstract, *candidates])
142
+
143
+
118
144
  def review_analysis(analysis: PaperAnalysis, paper: RawPaper, skill: SkillSpec) -> PaperAnalysis:
119
145
  """Review candidate concepts, open questions, and datasets before writing to the vault."""
120
146
  if not analysis.concepts and not analysis.open_questions and not analysis.datasets:
@@ -122,9 +148,9 @@ def review_analysis(analysis: PaperAnalysis, paper: RawPaper, skill: SkillSpec)
122
148
  return analysis
123
149
 
124
150
  agent: Agent[None, CriticReview] = Agent(
125
- model=settings.llm_model,
151
+ model=get_model(),
126
152
  output_type=CriticReview,
127
- system_prompt=_build_system_prompt(skill),
153
+ system_prompt=_build_system_prompt(skill, _relevance_context(analysis, paper)),
128
154
  )
129
155
 
130
156
  payload = {
@@ -5,7 +5,7 @@ import logging
5
5
  from pydantic_ai import Agent
6
6
 
7
7
  from swarm_notes.analyst import PaperAnalysis
8
- from swarm_notes.config import settings
8
+ from swarm_notes.config import get_model
9
9
  from swarm_notes.router import SkillSpec
10
10
 
11
11
  logger = logging.getLogger(__name__)
@@ -27,7 +27,7 @@ def discuss_papers(analyses: list[PaperAnalysis], skill: SkillSpec | None = None
27
27
  system_prompt += f"\n\nSKILL-SPECIFIC CONTEXT:\n{skill.discussant_context}"
28
28
 
29
29
  discussant_agent = Agent(
30
- model=settings.llm_model,
30
+ model=get_model(),
31
31
  system_prompt=system_prompt
32
32
  )
33
33
 
@@ -6,7 +6,7 @@ from bs4 import BeautifulSoup
6
6
  from pydantic_ai import Agent
7
7
 
8
8
  from swarm_notes.analyst import OpenQuestion
9
- from swarm_notes.config import settings
9
+ from swarm_notes.config import get_model, settings
10
10
  from swarm_notes.router import SkillSpec
11
11
 
12
12
  logger = logging.getLogger(__name__)
@@ -61,7 +61,7 @@ def extract_open_questions(
61
61
  system_prompt += f"\n\nSKILL-SPECIFIC CONTEXT:\n{skill.domain_expert_context}"
62
62
 
63
63
  agent: Agent[None, list[OpenQuestion]] = Agent(
64
- model=settings.llm_model,
64
+ model=get_model(),
65
65
  output_type=list[OpenQuestion],
66
66
  system_prompt=system_prompt
67
67
  )
@@ -9,7 +9,9 @@ partial corruption of the live vault.
9
9
  from __future__ import annotations
10
10
 
11
11
  import logging
12
+ import re
12
13
  import shutil
14
+ from collections import Counter
13
15
  from pathlib import Path
14
16
 
15
17
  import frontmatter
@@ -88,42 +90,117 @@ def _merge_directory(src: Path, dst: Path) -> None:
88
90
  logger.debug("Staged %s → %s", src_file, dst_file)
89
91
 
90
92
 
91
- def get_existing_tags() -> list[str]:
92
- """Return a deduplicated list of all tags currently found in live vault papers."""
93
- if not settings.vault_papers_dir.exists():
93
+ _ENTITY_DIR_SETTINGS = {
94
+ "concepts": "vault_concepts_dir",
95
+ "datasets": "vault_datasets_dir",
96
+ "open_questions": "vault_open_questions_dir",
97
+ }
98
+
99
+ # Cache of parsed paper frontmatter, keyed on the papers directory and its
100
+ # state. A run only writes to tmp_vault/, so the live vault is parsed once per
101
+ # run instead of once per prompt.
102
+ _paper_metadata_cache: dict[tuple, list[dict]] = {}
103
+
104
+
105
+ def _load_paper_metadata() -> list[dict]:
106
+ """Return the frontmatter of every live vault paper, parsed once per vault state."""
107
+ papers_dir = settings.vault_papers_dir
108
+ if not papers_dir.exists():
94
109
  return []
95
- tags_set = set()
96
- for f in settings.vault_papers_dir.glob("*.md"):
110
+ files = sorted(papers_dir.glob("*.md"))
111
+ key = (str(papers_dir), papers_dir.stat().st_mtime_ns, len(files))
112
+ cached = _paper_metadata_cache.get(key)
113
+ if cached is not None:
114
+ return cached
115
+ metadata: list[dict] = []
116
+ for f in files:
97
117
  try:
98
- post = frontmatter.load(f)
99
- file_tags = post.metadata.get("tags", [])
100
- if isinstance(file_tags, list):
101
- for t in file_tags:
102
- if isinstance(t, str) and t.strip():
103
- tags_set.add(t.strip())
104
- elif isinstance(file_tags, str) and file_tags.strip():
105
- tags_set.add(file_tags.strip())
118
+ metadata.append(frontmatter.load(f).metadata)
106
119
  except Exception as exc:
107
- logger.warning("VaultManager: failed to parse tags from %s: %s", f, exc)
108
- return sorted(list(tags_set))
120
+ logger.warning("VaultManager: failed to parse frontmatter from %s: %s", f, exc)
121
+ _paper_metadata_cache.clear()
122
+ _paper_metadata_cache[key] = metadata
123
+ return metadata
124
+
125
+
126
+ def get_tag_counts() -> Counter[str]:
127
+ """Return how many live vault papers use each tag."""
128
+ counts: Counter[str] = Counter()
129
+ for meta in _load_paper_metadata():
130
+ file_tags = meta.get("tags", [])
131
+ if isinstance(file_tags, str):
132
+ file_tags = [file_tags]
133
+ if not isinstance(file_tags, list):
134
+ continue
135
+ # Some older notes hold several tags in one comma-joined string.
136
+ split = {part.strip() for t in file_tags if isinstance(t, str) for part in t.split(",")}
137
+ for t in split - {""}:
138
+ counts[t] += 1
139
+ return counts
140
+
141
+
142
+ def get_existing_tags() -> list[str]:
143
+ """Return a deduplicated list of all tags currently found in live vault papers."""
144
+ return sorted(get_tag_counts())
109
145
 
110
146
 
111
147
  def get_existing_entities(entity_type: str) -> list[str]:
112
- """Return a deduplicated list of entity names (e.g. concepts, datasets, open_questions) from live vault papers."""
113
- if not settings.vault_papers_dir.exists():
114
- return []
115
- entity_set = set()
116
- for f in settings.vault_papers_dir.glob("*.md"):
117
- try:
118
- post = frontmatter.load(f)
119
- entities = post.metadata.get(entity_type, [])
120
- if isinstance(entities, list):
121
- for e in entities:
122
- if isinstance(e, dict) and "name" in e:
123
- entity_set.add(e["name"])
124
- except Exception as exc:
125
- pass
126
- return sorted(list(entity_set))
148
+ """Return entity names (e.g. concepts, datasets, open_questions) known to the vault.
149
+
150
+ Names come from paper frontmatter and from the slugs of the standalone
151
+ entity notes (``vault/concepts/<slug>.md`` and so on).
152
+ """
153
+ entity_set: set[str] = set()
154
+ for meta in _load_paper_metadata():
155
+ entities = meta.get(entity_type, [])
156
+ if isinstance(entities, list):
157
+ for e in entities:
158
+ if isinstance(e, dict) and e.get("name"):
159
+ entity_set.add(str(e["name"]))
160
+ dir_setting = _ENTITY_DIR_SETTINGS.get(entity_type)
161
+ entity_dir = getattr(settings, dir_setting, None) if dir_setting else None
162
+ if entity_dir is not None and entity_dir.exists():
163
+ entity_set.update(f.stem for f in entity_dir.glob("*.md") if not f.name.startswith("_"))
164
+ return sorted(entity_set)
165
+
166
+
167
+ _WORD_RE = re.compile(r"[a-z0-9]+")
168
+ _STOPWORDS = frozenset({
169
+ "and", "are", "based", "for", "from", "into", "its", "new", "our",
170
+ "the", "their", "this", "that", "towards", "using", "via", "with",
171
+ })
172
+
173
+
174
+ def _words(text: str) -> set[str]:
175
+ return {w for w in _WORD_RE.findall(text.lower()) if len(w) > 2 and w not in _STOPWORDS}
176
+
177
+
178
+ def select_relevant(
179
+ names: list[str],
180
+ text: str,
181
+ limit: int,
182
+ counts: Counter[str] | None = None,
183
+ ) -> list[str]:
184
+ """Pick at most *limit* names, the ones sharing the most words with *text* first.
185
+
186
+ Names with no word in common are used only to fill the remaining slots,
187
+ most frequent first (by *counts*). This keeps prompts a fixed size however
188
+ large the vault grows, while still showing the names a paper is likely to
189
+ reuse.
190
+ """
191
+ if len(names) <= limit:
192
+ return list(names)
193
+ text_words = _words(text)
194
+ counts = counts or Counter()
195
+
196
+ def rank(name: str) -> tuple:
197
+ name_words = _words(name)
198
+ shared = len(name_words & text_words)
199
+ # More shared words first, then names that are mostly shared words
200
+ # ("forecasting" over "forecasting-for-sparse-retail-demand").
201
+ return (-shared, -shared / max(len(name_words), 1), -counts.get(name, 0), name)
202
+
203
+ return sorted(names, key=rank)[:limit]
127
204
 
128
205
 
129
206
  def make_storage_id(source: str, paper_id: str) -> str: