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.
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/PKG-INFO +12 -1
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/README.md +11 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/pyproject.toml +1 -1
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/analyst.py +52 -18
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/config.py +12 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/critic.py +37 -11
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/discussant.py +2 -2
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/domain_expert.py +2 -2
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/vault_manager.py +107 -30
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/__init__.py +0 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/federation.py +0 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/main.py +0 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/__init__.py +0 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/arxiv.py +0 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/base.py +0 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/biorxiv.py +0 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/common.py +0 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/openalex.py +0 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/paper_search/semantic_scholar.py +0 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/pdf_converter.py +0 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/router.py +0 -0
- {swarm_notes-0.3.4 → swarm_notes-0.3.6}/src/swarm_notes/vault_writer.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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=
|
|
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
|
|
336
|
-
"""
|
|
337
|
-
|
|
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
|
-
|
|
342
|
-
if not
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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=
|
|
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
|
|
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=
|
|
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=
|
|
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
|
-
|
|
92
|
-
""
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
108
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|