swarm-notes 0.3.3__tar.gz → 0.3.4__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.3 → swarm_notes-0.3.4}/PKG-INFO +10 -2
  2. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/README.md +9 -1
  3. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/pyproject.toml +1 -1
  4. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/analyst.py +60 -64
  5. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/main.py +7 -0
  6. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/vault_writer.py +11 -2
  7. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/__init__.py +0 -0
  8. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/config.py +0 -0
  9. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/critic.py +0 -0
  10. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/discussant.py +0 -0
  11. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/domain_expert.py +0 -0
  12. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/federation.py +0 -0
  13. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/__init__.py +0 -0
  14. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/arxiv.py +0 -0
  15. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/base.py +0 -0
  16. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/biorxiv.py +0 -0
  17. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/common.py +0 -0
  18. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/openalex.py +0 -0
  19. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/semantic_scholar.py +0 -0
  20. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/pdf_converter.py +0 -0
  21. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/router.py +0 -0
  22. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/src/swarm_notes/vault_manager.py +0 -0
  23. {swarm_notes-0.3.3 → swarm_notes-0.3.4}/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
3
+ Version: 0.3.4
4
4
  Summary: Automated research paper tracking and knowledge synthesis
5
5
  Author: LM
6
6
  Author-email: LM <hi@leima.is>
@@ -237,4 +237,12 @@ swarm-notes run \
237
237
 
238
238
  ## License
239
239
 
240
- MIT — see [LICENSE](LICENSE).
240
+ MIT — see [LICENSE](LICENSE).
241
+ ## Releasing
242
+
243
+ Releases are published to PyPI by `.github/workflows/release.yml` when a `v*` tag is pushed:
244
+
245
+ 1. Bump `version` in `pyproject.toml` and merge to `main`.
246
+ 2. Tag the merge commit with the same version, e.g. `git tag v0.3.4 && git push origin v0.3.4`.
247
+
248
+ The workflow checks that the tag matches `pyproject.toml`, runs the tests, builds, and publishes with PyPI trusted publishing. One-time setup: add this repository as a trusted publisher for `swarm-notes` on PyPI (workflow `release.yml`, environment `pypi`).
@@ -218,4 +218,12 @@ swarm-notes run \
218
218
 
219
219
  ## License
220
220
 
221
- MIT — see [LICENSE](LICENSE).
221
+ MIT — see [LICENSE](LICENSE).
222
+ ## Releasing
223
+
224
+ Releases are published to PyPI by `.github/workflows/release.yml` when a `v*` tag is pushed:
225
+
226
+ 1. Bump `version` in `pyproject.toml` and merge to `main`.
227
+ 2. Tag the merge commit with the same version, e.g. `git tag v0.3.4 && git push origin v0.3.4`.
228
+
229
+ The workflow checks that the tag matches `pyproject.toml`, runs the tests, builds, and publishes with PyPI trusted publishing. One-time setup: add this repository as a trusted publisher for `swarm-notes` on PyPI (workflow `release.yml`, environment `pypi`).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "swarm-notes"
3
- version = "0.3.3"
3
+ version = "0.3.4"
4
4
  description = "Automated research paper tracking and knowledge synthesis"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -41,21 +41,21 @@ class ConceptLink(BaseModel):
41
41
  description="One sentence that defines this concept in the context of the paper."
42
42
  )
43
43
  importance_reason: str = Field(
44
- # default="",
44
+ default="",
45
45
  description=(
46
46
  "Why this concept is central to the paper's contribution or novelty. "
47
47
  "Leave empty if not applicable."
48
48
  ),
49
49
  )
50
50
  reusability_reason: str = Field(
51
- # default="",
51
+ default="",
52
52
  description=(
53
53
  "Why this concept is likely to recur across future papers and deserves "
54
54
  "a permanent vault note. Leave empty if not applicable."
55
55
  ),
56
56
  )
57
57
  evidence_excerpt: str = Field(
58
- # default="",
58
+ default="",
59
59
  description=(
60
60
  "Short phrase or sentence from the title or abstract supporting this concept. "
61
61
  "Leave empty if not applicable."
@@ -79,14 +79,14 @@ class OpenQuestion(BaseModel):
79
79
  description="Detailed explanation of the open question or future work."
80
80
  )
81
81
  importance_reason: str = Field(
82
- # default="",
82
+ default="",
83
83
  description=(
84
84
  "Why this open question is technically important and worth tracking across papers. "
85
85
  "Leave empty if not applicable."
86
86
  ),
87
87
  )
88
88
  evidence_excerpt: str = Field(
89
- # default="",
89
+ default="",
90
90
  description=(
91
91
  "Short phrase or sentence from the paper text supporting that this question is explicit and unresolved. "
92
92
  "Leave empty if not applicable."
@@ -155,7 +155,7 @@ class PaperAnalysisBase(BaseModel):
155
155
  )
156
156
 
157
157
  architectures: list[str] = Field(
158
- # default_factory=list,
158
+ default_factory=list,
159
159
  description=(
160
160
  "Architecture names used or proposed (from the taxonomy architectures list). "
161
161
  "Leave empty if not applicable."
@@ -163,7 +163,7 @@ class PaperAnalysisBase(BaseModel):
163
163
  )
164
164
 
165
165
  datasets: list[DatasetLink] = Field(
166
- # default_factory=list,
166
+ default_factory=list,
167
167
  description=(
168
168
  "Names of CRITICAL datasets used for evaluation or training "
169
169
  "(e.g. 'ImageNet', 'ETTh1', 'LAION-5B'). "
@@ -172,7 +172,7 @@ class PaperAnalysisBase(BaseModel):
172
172
  )
173
173
 
174
174
  concepts: list[ConceptLink] = Field(
175
- # default_factory=list,
175
+ default_factory=list,
176
176
  description=(
177
177
  "Key concepts introduced or heavily used by the paper that "
178
178
  "should have their own vault entry (e.g. a new architecture, "
@@ -181,7 +181,7 @@ class PaperAnalysisBase(BaseModel):
181
181
  )
182
182
 
183
183
  limitations: str = Field(
184
- # default="",
184
+ default="",
185
185
  description="Brief statement of known limitations or future work.",
186
186
  )
187
187
 
@@ -193,34 +193,54 @@ class PaperAnalysisBase(BaseModel):
193
193
  )
194
194
 
195
195
  open_questions: list[OpenQuestion] = Field(
196
- # default_factory=list,
196
+ default_factory=list,
197
197
  description="List of open questions or future research identified by the domain expert.",
198
198
  )
199
199
 
200
200
  critic_review_summary: str = Field(
201
- # default="",
201
+ default="",
202
202
  description="Short archivist review summary explaining why items were accepted or rejected.",
203
203
  )
204
204
 
205
205
  critic_rejected_candidates: list[RejectedCandidate] = Field(
206
- # default_factory=list,
206
+ default_factory=list,
207
207
  description="Structured rejection records for candidate concepts or open questions rejected by the archivist review.",
208
208
  )
209
209
 
210
210
 
211
211
  class PaperAnalysis(PaperAnalysisBase):
212
212
  """Structured extraction output for a single academic paper."""
213
- tags: list[str] = Field(default_factory=list, description="All assigned tags.")
213
+ tags: list[str] = Field(
214
+ default_factory=list,
215
+ description=(
216
+ "Tags for this paper. Reuse tags from the EXISTING TAGS list whenever one applies; "
217
+ "only add a new tag if no existing tag describes the idea. Maximum 10 tags."
218
+ ),
219
+ )
220
+
221
+
222
+ # Upper bound on how many existing tags are listed in the prompt. Tags are
223
+ # passed as prompt text rather than as an enum in the output schema: a schema
224
+ # enum with hundreds of values makes Gemini reject the request with
225
+ # 400 INVALID_ARGUMENT.
226
+ MAX_PROMPT_TAGS = 1000
214
227
 
215
228
 
216
229
  # ---------------------------------------------------------------------------
217
230
  # Agent factory
218
231
  # ---------------------------------------------------------------------------
219
232
 
220
- def _build_system_prompt(skill: SkillSpec, taxonomy: dict) -> str:
233
+ def _build_system_prompt(
234
+ skill: SkillSpec, taxonomy: dict, existing_tags: list[str] | None = None
235
+ ) -> str:
221
236
  architectures_list = ", ".join(taxonomy.get("architectures", []))
222
237
  domains_list = ", ".join(taxonomy.get("domains", []))
223
-
238
+ existing_tags_str = (
239
+ ", ".join(existing_tags[:MAX_PROMPT_TAGS])
240
+ if existing_tags
241
+ else "(No tags currently exist in the vault)"
242
+ )
243
+
224
244
  base_prompt = f"""You are an expert academic paper analyst specialising in machine learning research.
225
245
 
226
246
  Your task is to analyse the provided paper abstract and extract structured information.
@@ -229,11 +249,14 @@ TAXONOMY (use ONLY these values for architectures/domains):
229
249
  - Available architectures: {architectures_list}
230
250
  - Available domains: {domains_list}
231
251
 
252
+ EXISTING TAGS IN VAULT:
253
+ {existing_tags_str}
254
+
232
255
  SKILL CONTEXT ({skill.name}):
233
256
  {skill.extra_system_prompt}
234
257
 
235
258
  IMPORTANT RULES:
236
- 1. Select tags according to the dynamic schema.
259
+ 1. Prefer tags from the EXISTING TAGS list, spelled exactly as listed. Add a new tag only when none applies, and keep new tags to an absolute minimum.
237
260
  2. Select domain ONLY from the taxonomy domains list above.
238
261
  3. Select architectures ONLY from the taxonomy architectures list above (or leave empty).
239
262
  4. The summary must be 3-5 sentences, technically precise but accessible.
@@ -268,46 +291,12 @@ def analyse(paper: RawPaper, skill: SkillSpec) -> PaperAnalysis:
268
291
  Structured extraction result.
269
292
  """
270
293
  taxonomy = _load_taxonomy()
271
- system_prompt = _build_system_prompt(skill, taxonomy)
272
294
  existing_tags = get_existing_tags()
273
-
274
- from pydantic import create_model
275
-
276
- fields = {}
277
- if existing_tags:
278
- ExistingTags = Literal[tuple(existing_tags)] # type: ignore
279
- fields["existing_tags"] = (
280
- list[ExistingTags],
281
- Field(
282
- default_factory=list,
283
- description="Tags chosen from the existing knowledge base. Pick only tags that genuinely apply."
284
- )
285
- )
286
- fields["new_tags"] = (
287
- list[str],
288
- Field(
289
- default_factory=list,
290
- description="Use this ONLY if the concept cannot be accurately described by any of the existing_tags. Keep new tags to an absolute minimum."
291
- )
292
- )
293
- else:
294
- fields["new_tags"] = (
295
- list[str],
296
- Field(
297
- default_factory=list,
298
- description="List of new tags for this paper."
299
- )
300
- )
301
-
302
- DynamicPaperAnalysis = create_model(
303
- "DynamicPaperAnalysis",
304
- __base__=PaperAnalysisBase,
305
- **fields
306
- )
295
+ system_prompt = _build_system_prompt(skill, taxonomy, existing_tags)
307
296
 
308
- agent: Agent[None, DynamicPaperAnalysis] = Agent(
297
+ agent: Agent[None, PaperAnalysis] = Agent(
309
298
  model=settings.llm_model,
310
- output_type=DynamicPaperAnalysis,
299
+ output_type=PaperAnalysis,
311
300
  system_prompt=system_prompt,
312
301
  )
313
302
 
@@ -323,17 +312,8 @@ def analyse(paper: RawPaper, skill: SkillSpec) -> PaperAnalysis:
323
312
  logger.info("Analyst: analysing paper %s with skill %s", paper.arxiv_id, skill.name)
324
313
 
325
314
  result = agent.run_sync(user_message)
326
- dynamic_analysis = result.output
327
-
328
- # Merge existing and new tags
329
- combined_tags = []
330
- if hasattr(dynamic_analysis, "existing_tags"):
331
- combined_tags.extend(getattr(dynamic_analysis, "existing_tags", []))
332
- if hasattr(dynamic_analysis, "new_tags"):
333
- combined_tags.extend(getattr(dynamic_analysis, "new_tags", []))
334
-
335
- analysis_dict = dynamic_analysis.model_dump(exclude={"existing_tags", "new_tags"})
336
- analysis = PaperAnalysis(**analysis_dict, tags=combined_tags)
315
+ analysis: PaperAnalysis = result.output
316
+ analysis.tags = _normalise_tags(analysis.tags, existing_tags)
337
317
 
338
318
  # Ensure metadata from the raw paper is preserved accurately
339
319
  analysis.arxiv_id = paper.arxiv_id
@@ -352,6 +332,22 @@ def analyse(paper: RawPaper, skill: SkillSpec) -> PaperAnalysis:
352
332
  return analysis
353
333
 
354
334
 
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}
338
+ result: list[str] = []
339
+ seen: set[str] = set()
340
+ for tag in tags:
341
+ tag = tag.strip()
342
+ if not tag:
343
+ continue
344
+ tag = canonical.get(tag.lower(), tag)
345
+ if tag.lower() not in seen:
346
+ seen.add(tag.lower())
347
+ result.append(tag)
348
+ return result
349
+
350
+
355
351
  def _load_taxonomy() -> dict:
356
352
  """Load taxonomy.json, returning an empty dict on failure."""
357
353
  if not settings.taxonomy_file.exists():
@@ -328,6 +328,13 @@ def run(
328
328
  len(papers),
329
329
  )
330
330
 
331
+ if not analyses:
332
+ # Every paper failed (e.g. the LLM rejected every request). Exit
333
+ # non-zero so the scheduled workflow shows up as failed instead of
334
+ # silently committing nothing.
335
+ logger.error("No papers were processed successfully – failing the run")
336
+ raise typer.Exit(1)
337
+
331
338
  except typer.Exit:
332
339
  # Re-raise Typer exit as-is
333
340
  raise
@@ -616,5 +616,14 @@ def _slugify(text: str) -> str:
616
616
 
617
617
 
618
618
  def _yaml_escape(text: str) -> str:
619
- """Escape double quotes in YAML string values."""
620
- return text.replace('"', '\\"')
619
+ """Escape text for use inside a double-quoted YAML scalar.
620
+
621
+ Backslashes must be escaped first: titles with LaTeX such as
622
+ ``$\\mathrm{SO}(2)$`` otherwise produce invalid escape sequences and
623
+ break every YAML parser that reads the note (including the site build).
624
+ """
625
+ return (
626
+ text.replace("\\", "\\\\")
627
+ .replace('"', '\\"')
628
+ .replace("\n", " ")
629
+ )