swarm-notes 0.3.2__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.2 → swarm_notes-0.3.4}/PKG-INFO +10 -2
  2. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/README.md +9 -1
  3. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/pyproject.toml +1 -1
  4. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/analyst.py +48 -52
  5. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/main.py +7 -0
  6. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/vault_writer.py +11 -2
  7. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/__init__.py +0 -0
  8. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/config.py +0 -0
  9. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/critic.py +0 -0
  10. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/discussant.py +0 -0
  11. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/domain_expert.py +0 -0
  12. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/federation.py +0 -0
  13. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/__init__.py +0 -0
  14. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/arxiv.py +0 -0
  15. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/base.py +0 -0
  16. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/biorxiv.py +0 -0
  17. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/common.py +0 -0
  18. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/openalex.py +0 -0
  19. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/paper_search/semantic_scholar.py +0 -0
  20. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/pdf_converter.py +0 -0
  21. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/router.py +0 -0
  22. {swarm_notes-0.3.2 → swarm_notes-0.3.4}/src/swarm_notes/vault_manager.py +0 -0
  23. {swarm_notes-0.3.2 → 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.2
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.2"
3
+ version = "0.3.4"
4
4
  description = "Automated research paper tracking and knowledge synthesis"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -210,17 +210,37 @@ class PaperAnalysisBase(BaseModel):
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
+ )