swarm-notes 0.2.0__tar.gz → 0.3.0__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.2.0 → swarm_notes-0.3.0}/PKG-INFO +1 -1
  2. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/pyproject.toml +1 -1
  3. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/analyst.py +69 -29
  4. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/critic.py +6 -12
  5. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/vault_manager.py +37 -4
  6. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/vault_writer.py +52 -198
  7. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/README.md +0 -0
  8. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/__init__.py +0 -0
  9. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/config.py +0 -0
  10. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/discussant.py +0 -0
  11. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/domain_expert.py +0 -0
  12. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/federation.py +0 -0
  13. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/main.py +0 -0
  14. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/__init__.py +0 -0
  15. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/arxiv.py +0 -0
  16. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/base.py +0 -0
  17. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/biorxiv.py +0 -0
  18. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/common.py +0 -0
  19. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/openalex.py +0 -0
  20. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/semantic_scholar.py +0 -0
  21. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/pdf_converter.py +0 -0
  22. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/router.py +0 -0
  23. {swarm_notes-0.2.0 → swarm_notes-0.3.0}/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.2.0
3
+ Version: 0.3.0
4
4
  Summary: Automated research paper tracking and knowledge synthesis
5
5
  Author: LM
6
6
  Author-email: LM <hi@leima.is>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "swarm-notes"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Automated research paper tracking and knowledge synthesis"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -15,7 +15,7 @@ from pydantic_ai import Agent
15
15
 
16
16
  from swarm_notes.config import settings
17
17
  from swarm_notes.router import SkillSpec
18
- from swarm_notes.vault_manager import get_existing_concept_slugs
18
+ from swarm_notes.vault_manager import get_existing_tags
19
19
  from swarm_notes.watcher import RawPaper
20
20
 
21
21
  logger = logging.getLogger(__name__)
@@ -124,8 +124,14 @@ class RejectedCandidate(BaseModel):
124
124
  )
125
125
 
126
126
 
127
- class PaperAnalysis(BaseModel):
128
- """Structured extraction output for a single academic paper."""
127
+ class DatasetLink(BaseModel):
128
+ """A dataset mentioned in the paper."""
129
+ name: str = Field(description="Name of the dataset (e.g. 'european-space-agency-anomaly-dataset').")
130
+ description: str = Field(description="Description of the dataset and how it is used in the paper.")
131
+
132
+
133
+ class PaperAnalysisBase(BaseModel):
134
+ """Structured extraction output for a single academic paper (Base schema)."""
129
135
 
130
136
  title: str = Field(description="Clean, full title of the paper.")
131
137
  authors: list[str] = Field(description="List of author full names.")
@@ -148,13 +154,6 @@ class PaperAnalysis(BaseModel):
148
154
  description="Bullet-point list of 3-5 key contributions."
149
155
  )
150
156
 
151
- tags: list[str] = Field(
152
- description=(
153
- "Relevant tags from the provided taxonomy. "
154
- "Choose only tags that genuinely apply. Maximum 10 tags."
155
- )
156
- )
157
-
158
157
  architectures: list[str] = Field(
159
158
  default_factory=list,
160
159
  description=(
@@ -163,7 +162,7 @@ class PaperAnalysis(BaseModel):
163
162
  ),
164
163
  )
165
164
 
166
- datasets: list[str] = Field(
165
+ datasets: list[DatasetLink] = Field(
167
166
  default_factory=list,
168
167
  description=(
169
168
  "Names of CRITICAL datasets used for evaluation or training "
@@ -209,35 +208,32 @@ class PaperAnalysis(BaseModel):
209
208
  )
210
209
 
211
210
 
211
+ class PaperAnalysis(PaperAnalysisBase):
212
+ """Structured extraction output for a single academic paper."""
213
+ tags: list[str] = Field(default_factory=list, description="All assigned tags.")
214
+
215
+
212
216
  # ---------------------------------------------------------------------------
213
217
  # Agent factory
214
218
  # ---------------------------------------------------------------------------
215
219
 
216
220
  def _build_system_prompt(skill: SkillSpec, taxonomy: dict) -> str:
217
- tags_list = ", ".join(taxonomy.get("tags", []))
218
221
  architectures_list = ", ".join(taxonomy.get("architectures", []))
219
222
  domains_list = ", ".join(taxonomy.get("domains", []))
220
223
 
221
- existing_concepts_list = get_existing_concept_slugs()
222
- existing_concepts_str = ", ".join(existing_concepts_list) if existing_concepts_list else "(No concepts currently exist in the vault)"
223
-
224
224
  base_prompt = f"""You are an expert academic paper analyst specialising in machine learning research.
225
225
 
226
226
  Your task is to analyse the provided paper abstract and extract structured information.
227
227
 
228
- TAXONOMY (use ONLY these values for tags/architectures/domains):
229
- - Available tags: {tags_list}
228
+ TAXONOMY (use ONLY these values for architectures/domains):
230
229
  - Available architectures: {architectures_list}
231
230
  - Available domains: {domains_list}
232
231
 
233
- EXISTING CONCEPTS IN VAULT:
234
- {existing_concepts_str}
235
-
236
232
  SKILL CONTEXT ({skill.name}):
237
233
  {skill.extra_system_prompt}
238
234
 
239
235
  IMPORTANT RULES:
240
- 1. Select tags ONLY from the taxonomy tag list above.
236
+ 1. Select tags according to the dynamic schema.
241
237
  2. Select domain ONLY from the taxonomy domains list above.
242
238
  3. Select architectures ONLY from the taxonomy architectures list above (or leave empty).
243
239
  4. The summary must be 3-5 sentences, technically precise but accessible.
@@ -246,11 +242,10 @@ IMPORTANT RULES:
246
242
  7. Default to zero concepts unless a concept is central to the paper's novelty and likely to matter beyond this single paper.
247
243
  8. For each proposed concept, fill importance_reason, reusability_reason, and evidence_excerpt with concrete, specific justification from the title or abstract.
248
244
  9. Reject generic terminology, routine datasets, benchmark names, and umbrella phrases unless the paper makes them a distinct reusable idea.
249
- 10. For concepts, ALWAYS check the EXISTING CONCEPTS IN VAULT list. If a highly synonymous concept already exists, you MUST exactly reuse its slug. Only create entirely new concepts if they represent a genuinely novel idea.
250
- 11. Prefer 0-2 excellent concept candidates over 5 weak ones.
251
- 12. Datasets are also candidate proposals for archival notes. Include only critical, named datasets that are central to evaluation claims.
252
- 13. Reject generic or aggregate dataset labels like "real-world datasets", "benchmark datasets", "10 datasets", or unnamed proprietary buckets.
253
- 14. Prefer 0-2 datasets. If uncertain a dataset is important enough for a standalone note, omit it.
245
+ 10. Prefer 0-2 excellent concept candidates over 5 weak ones.
246
+ 11. Datasets are also candidate proposals for archival notes. Include only critical, named datasets that are central to evaluation claims.
247
+ 12. Reject generic or aggregate dataset labels like "real-world datasets", "benchmark datasets", "10 datasets", or unnamed proprietary buckets.
248
+ 13. Prefer 0-2 datasets. If uncertain a dataset is important enough for a standalone note, omit it.
254
249
  """
255
250
  if skill.analyst_system_prompt_override:
256
251
  return skill.analyst_system_prompt_override
@@ -274,10 +269,45 @@ def analyse(paper: RawPaper, skill: SkillSpec) -> PaperAnalysis:
274
269
  """
275
270
  taxonomy = _load_taxonomy()
276
271
  system_prompt = _build_system_prompt(skill, taxonomy)
272
+ 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
+ )
277
307
 
278
- agent: Agent[None, PaperAnalysis] = Agent(
308
+ agent: Agent[None, DynamicPaperAnalysis] = Agent(
279
309
  model=settings.llm_model,
280
- output_type=PaperAnalysis,
310
+ output_type=DynamicPaperAnalysis,
281
311
  system_prompt=system_prompt,
282
312
  )
283
313
 
@@ -293,7 +323,17 @@ def analyse(paper: RawPaper, skill: SkillSpec) -> PaperAnalysis:
293
323
  logger.info("Analyst: analysing paper %s with skill %s", paper.arxiv_id, skill.name)
294
324
 
295
325
  result = agent.run_sync(user_message)
296
- analysis: PaperAnalysis = result.output
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)
297
337
 
298
338
  # Ensure metadata from the raw paper is preserved accurately
299
339
  analysis.arxiv_id = paper.arxiv_id
@@ -9,10 +9,10 @@ from pathlib import Path
9
9
  from pydantic import BaseModel, Field
10
10
  from pydantic_ai import Agent
11
11
 
12
- from swarm_notes.analyst import ConceptLink, OpenQuestion, PaperAnalysis, RejectedCandidate
12
+ from swarm_notes.analyst import ConceptLink, DatasetLink, OpenQuestion, PaperAnalysis, RejectedCandidate
13
13
  from swarm_notes.config import settings
14
14
  from swarm_notes.router import SkillSpec
15
- from swarm_notes.vault_manager import get_existing_concept_slugs
15
+ from swarm_notes.vault_manager import get_existing_entities
16
16
  from swarm_notes.watcher import RawPaper
17
17
 
18
18
  logger = logging.getLogger(__name__)
@@ -44,7 +44,7 @@ class CriticReview(BaseModel):
44
44
  "Return an empty list if none qualify."
45
45
  ),
46
46
  )
47
- approved_datasets: list[str] = Field(
47
+ approved_datasets: list[DatasetLink] = Field(
48
48
  default_factory=list,
49
49
  description=(
50
50
  "Only critical named datasets that deserve standalone notes. "
@@ -62,11 +62,11 @@ class CriticReview(BaseModel):
62
62
 
63
63
 
64
64
  def _build_system_prompt(skill: SkillSpec) -> str:
65
- existing_concepts = get_existing_concept_slugs()
65
+ existing_concepts = get_existing_entities("concepts")
66
66
  existing_concepts_str = ", ".join(existing_concepts) if existing_concepts else "(No concepts currently exist in the vault)"
67
- existing_open_questions = _get_existing_slugs(settings.vault_open_questions_dir)
67
+ existing_open_questions = get_existing_entities("open_questions")
68
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_slugs(settings.vault_datasets_dir)
69
+ existing_datasets = get_existing_entities("datasets")
70
70
  existing_datasets_str = ", ".join(existing_datasets) if existing_datasets else "(No datasets currently exist in the vault)"
71
71
 
72
72
  prompt = f"""You are a highly selective research archivist protecting a long-lived ML knowledge vault.
@@ -167,9 +167,3 @@ def review_analysis(analysis: PaperAnalysis, paper: RawPaper, skill: SkillSpec)
167
167
 
168
168
  return analysis
169
169
 
170
-
171
- def _get_existing_slugs(directory: Path) -> list[str]:
172
- """Return existing note slugs from a vault directory."""
173
- if not directory.exists():
174
- return []
175
- return sorted(path.stem for path in directory.glob("*.md"))
@@ -12,6 +12,8 @@ import logging
12
12
  import shutil
13
13
  from pathlib import Path
14
14
 
15
+ import frontmatter
16
+
15
17
  from swarm_notes.config import settings
16
18
 
17
19
  logger = logging.getLogger(__name__)
@@ -86,11 +88,42 @@ def _merge_directory(src: Path, dst: Path) -> None:
86
88
  logger.debug("Staged %s → %s", src_file, dst_file)
87
89
 
88
90
 
89
- def get_existing_concept_slugs() -> list[str]:
90
- """Return a list of all concept slugs currently in the live vault."""
91
- if not settings.vault_concepts_dir.exists():
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():
94
+ return []
95
+ tags_set = set()
96
+ for f in settings.vault_papers_dir.glob("*.md"):
97
+ 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())
106
+ except Exception as exc:
107
+ logger.warning("VaultManager: failed to parse tags from %s: %s", f, exc)
108
+ return sorted(list(tags_set))
109
+
110
+
111
+ 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():
92
114
  return []
93
- return [f.stem for f in settings.vault_concepts_dir.glob("*.md")]
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))
94
127
 
95
128
 
96
129
  def make_storage_id(source: str, paper_id: str) -> str:
@@ -23,29 +23,35 @@ logger = logging.getLogger(__name__)
23
23
  # YAML frontmatter template
24
24
  _FRONTMATTER_TEMPLATE = """\
25
25
  ---
26
- # CSL-compatible fields
26
+ # OKF Required Field
27
+ type: "paper"
28
+
29
+ # OKF Recommended Core Fields
27
30
  title: "{title}"
31
+ description: "{description}"
32
+ resource: "{url}"
33
+ tags:
34
+ {tag_lines}
35
+ timestamp: "{processed_at}"
36
+
37
+ # CSL-compatible fields (Retained as custom)
28
38
  author:
29
39
  {author_lines}
30
40
  issued:
31
41
  date-parts:
32
42
  - [{date_parts}]
33
- url: "{url}"
34
43
 
35
- # Custom fields
44
+ # Domain-specific custom fields
36
45
  paper_id: "{paper_id}"
37
46
  paper_source: "{paper_source}"
38
- domain: "{domain}"
39
- tags:
40
- {tag_lines}
41
47
  architectures:
42
48
  {architecture_lines}
49
+ concepts:
50
+ {concept_lines}
43
51
  datasets:
44
52
  {dataset_lines}
45
- concept_slugs:
46
- {concept_slug_lines}
47
- dataset_slugs:
48
- {dataset_slug_lines}
53
+ open_questions:
54
+ {open_question_lines}
49
55
  skill: "{skill}"
50
56
  processed_at: "{processed_at}"
51
57
  created_at: "{created_at}"
@@ -78,21 +84,6 @@ def write_paper(analysis: PaperAnalysis, skill_name: str) -> Path:
78
84
  out_path.write_text(content, encoding="utf-8")
79
85
  logger.info("VaultWriter: wrote paper → %s", out_path)
80
86
 
81
- # Upsert concept notes and track this paper as a source mention.
82
- for concept in analysis.concepts:
83
- _write_concept_stub(
84
- concept.slug,
85
- concept.display_name,
86
- concept.one_liner,
87
- concept.importance_reason,
88
- concept.evidence_excerpt,
89
- slug,
90
- )
91
-
92
- # Ensure dataset notes exist for linked dataset slugs.
93
- for dataset_name in analysis.datasets:
94
- _write_dataset_stub(_slugify(dataset_name), dataset_name)
95
-
96
87
  return out_path
97
88
 
98
89
 
@@ -115,25 +106,41 @@ def _build_frontmatter(analysis: PaperAnalysis, skill_name: str) -> str:
115
106
  architecture_lines = (
116
107
  "\n".join(f' - "{a}"' for a in analysis.architectures) or " []"
117
108
  )
118
- dataset_lines = "\n".join(f' - "{d}"' for d in analysis.datasets) or " []"
119
- concept_slug_lines = "\n".join(f' - "{c.slug}"' for c in analysis.concepts) or " []"
120
- dataset_slug_lines = "\n".join(f' - "{_slugify(d)}"' for d in analysis.datasets) or " []"
109
+
110
+ def _render_okf_list(items) -> str:
111
+ if not items:
112
+ return " []"
113
+ lines = []
114
+ for item in items:
115
+ # Handle ConceptLink (slug/one_liner), DatasetLink (name/description), OpenQuestion (slug/description)
116
+ name = getattr(item, "slug", getattr(item, "name", ""))
117
+ desc = getattr(item, "one_liner", getattr(item, "description", ""))
118
+ lines.append(f' - name: "{_yaml_escape(name)}"\n description: "{_yaml_escape(desc)}"')
119
+ return "\n".join(lines)
120
+
121
+ concept_lines = _render_okf_list(analysis.concepts)
122
+ dataset_lines = _render_okf_list(analysis.datasets)
123
+ open_question_lines = _render_okf_list(analysis.open_questions)
121
124
 
122
125
  now_utc = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
126
+
127
+ # Escape summary for description field (strip newlines which might break YAML block if not multiline string)
128
+ safe_description = _yaml_escape(analysis.summary.replace('\n', ' '))
123
129
 
124
130
  return _FRONTMATTER_TEMPLATE.format(
125
131
  title=_yaml_escape(analysis.title),
132
+ description=safe_description,
133
+ url=analysis.url,
126
134
  author_lines=author_lines,
127
135
  date_parts=date_parts,
128
- url=analysis.url,
129
136
  paper_id=analysis.arxiv_id,
130
137
  paper_source=analysis.source,
131
138
  domain=analysis.domain,
132
139
  tag_lines=tag_lines,
133
140
  architecture_lines=architecture_lines,
141
+ concept_lines=concept_lines,
134
142
  dataset_lines=dataset_lines,
135
- concept_slug_lines=concept_slug_lines,
136
- dataset_slug_lines=dataset_slug_lines,
143
+ open_question_lines=open_question_lines,
137
144
  skill=skill_name,
138
145
  processed_at=now_utc,
139
146
  created_at=now_utc,
@@ -165,7 +172,6 @@ def _build_body(analysis: PaperAnalysis) -> str:
165
172
  lines.append("## Open Questions & Future Work\n")
166
173
  for q in analysis.open_questions:
167
174
  lines.append(f"- [[{q.slug}]]")
168
- _create_open_question(q, analysis)
169
175
  lines.append("")
170
176
 
171
177
  # Concepts
@@ -211,7 +217,7 @@ def _build_body(analysis: PaperAnalysis) -> str:
211
217
  if analysis.datasets:
212
218
  lines.append("## Datasets\n")
213
219
  for ds in analysis.datasets:
214
- ds_slug = _slugify(ds)
220
+ ds_slug = getattr(ds, "name", "unknown")
215
221
  lines.append(f"- [[{ds_slug}]]")
216
222
  lines.append("")
217
223
 
@@ -224,175 +230,11 @@ def _build_body(analysis: PaperAnalysis) -> str:
224
230
  return "\n".join(lines)
225
231
 
226
232
 
227
- def _write_concept_stub(
228
- slug: str,
229
- display_name: str,
230
- one_liner: str,
231
- importance_reason: str = "",
232
- evidence_excerpt: str = "",
233
- paper_slug: str = "",
234
- ) -> None:
235
- """Create or update a concept note and track source papers in metadata/content."""
236
- staging_path = settings.tmp_concepts_dir / f"{slug}.md"
237
- vault_path = settings.vault_concepts_dir / f"{slug}.md"
238
- paper_ref = f"[[{paper_slug}]]" if paper_slug else ""
239
-
240
- if vault_path.exists() and not staging_path.exists():
241
- shutil.copy2(vault_path, staging_path)
242
-
243
- now_utc = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
244
-
245
- if staging_path.exists():
246
- try:
247
- post = frontmatter.load(staging_path)
248
- source_papers = post.metadata.get("source_papers", [])
249
- if not isinstance(source_papers, list):
250
- source_papers = [source_papers]
251
- if paper_ref and paper_ref not in source_papers:
252
- source_papers.append(paper_ref)
253
-
254
- post.metadata["source_papers"] = source_papers
255
- post.metadata["modified_at"] = now_utc
256
- post.content = _ensure_related_paper_link(post.content, paper_ref)
257
-
258
- with staging_path.open("wb") as f:
259
- frontmatter.dump(post, f)
260
-
261
- logger.info("VaultWriter: updated concept note → %s", staging_path)
262
- return
263
- except Exception as exc:
264
- logger.warning("VaultWriter: Failed to update concept frontmatter for %s: %s", slug, exc)
265
-
266
- source_papers = [paper_ref] if paper_ref else []
267
- content = f"""\
268
- ---
269
- title: "{_yaml_escape(display_name)}"
270
- slug: "{slug}"
271
- type: concept
272
- generated_stub: true
273
- source_papers:
274
- {_yaml_list(source_papers)}
275
- processed_at: "{now_utc}"
276
- created_at: "{now_utc}"
277
- ---
278
-
279
- # {display_name}
280
-
281
- > *Auto-generated stub. Edit this file to add more details.*
282
-
283
- {one_liner}
284
-
285
- """
286
- if importance_reason:
287
- content += f"\n## Why It Matters\n\n{importance_reason}\n"
288
- if evidence_excerpt:
289
- content += f"\n## Evidence\n\n> {evidence_excerpt}\n"
290
- content += "\n## Related Papers\n\n"
291
- if paper_ref:
292
- content += f"- {paper_ref}\n"
293
-
294
- staging_path.write_text(content, encoding="utf-8")
295
- logger.info("VaultWriter: created concept stub → %s", staging_path)
296
-
297
-
298
- def _ensure_related_paper_link(content: str, paper_ref: str) -> str:
299
- """Ensure a concept body has a Related Papers section containing *paper_ref*."""
300
- if not paper_ref:
301
- return content
302
- if paper_ref in content:
303
- return content
304
- trimmed = content.rstrip()
305
- if "## Related Papers" in trimmed:
306
- return f"{trimmed}\n- {paper_ref}\n"
307
- return f"{trimmed}\n\n## Related Papers\n\n- {paper_ref}\n"
308
-
309
-
310
- def _write_dataset_stub(slug: str, display_name: str) -> None:
311
- """Write a dataset stub note if one does not already exist."""
312
- staging_path = settings.tmp_datasets_dir / f"{slug}.md"
313
- vault_path = settings.vault_datasets_dir / f"{slug}.md"
314
-
315
- if staging_path.exists() or vault_path.exists():
316
- logger.debug("Dataset stub already exists for '%s' - skipping", slug)
317
- return
318
-
319
- now_utc = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
320
- content = f"""\
321
- ---
322
- title: "{_yaml_escape(display_name)}"
323
- slug: "{slug}"
324
- type: dataset
325
- generated_stub: true
326
- processed_at: "{now_utc}"
327
- created_at: "{now_utc}"
328
- ---
329
-
330
- # {display_name}
331
-
332
- > *Auto-generated dataset stub. Edit this file to add more details.*
333
- """
334
- staging_path.write_text(content, encoding="utf-8")
335
- logger.info("VaultWriter: created dataset stub -> %s", staging_path)
336
-
337
-
338
233
  def _yaml_list(values: list[str]) -> str:
339
234
  """Render a YAML list with sane fallback for empty lists."""
340
235
  return "\n".join(f" - \"{_yaml_escape(v)}\"" for v in values) if values else " []"
341
236
 
342
237
 
343
- def _create_open_question(question: OpenQuestion, analysis: PaperAnalysis) -> None:
344
- """Create or update an open question file with frontmatter tracking sources."""
345
- slug = _slugify(question.slug)
346
- filename = f"{slug}.md"
347
- live_path = settings.vault_open_questions_dir / filename
348
- staging_path = settings.tmp_open_questions_dir / filename
349
-
350
- is_new = not staging_path.exists() and not live_path.exists()
351
-
352
- if live_path.exists() and not staging_path.exists():
353
- shutil.copy2(live_path, staging_path)
354
-
355
- now_utc = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
356
- papers = []
357
- content = f"**Background:** {question.background}\n\n**Question / Future Work:** {question.description}"
358
- if question.importance_reason:
359
- content += f"\n\n**Why It Matters:** {question.importance_reason}"
360
- if question.evidence_excerpt:
361
- content += f"\n\n**Evidence:** {question.evidence_excerpt}"
362
- existing_created_at: str | None = None
363
-
364
- if staging_path.exists():
365
- try:
366
- post = frontmatter.load(staging_path)
367
- papers = post.metadata.get("source_papers", [])
368
- if not isinstance(papers, list):
369
- papers = [papers]
370
- # Retain original deeper description instead of wiping it
371
- content = post.content if post.content.strip() else content
372
- existing_created_at = post.metadata.get("created_at")
373
- except Exception as exc:
374
- logger.warning("VaultWriter: Failed to parse existing frontmatter for %s: %s", slug, exc)
375
-
376
- paper_slug = _make_slug(analysis.source, analysis.arxiv_id, analysis.title)
377
- paper_id = f"[[{paper_slug}]]"
378
- if paper_id not in papers:
379
- papers.append(paper_id)
380
-
381
- metadata: dict = {
382
- "title": question.title,
383
- "source_papers": papers,
384
- "created_at": existing_created_at if existing_created_at else now_utc,
385
- }
386
- if not is_new:
387
- metadata["modified_at"] = now_utc
388
-
389
- post = frontmatter.Post(content, **metadata)
390
- with staging_path.open("wb") as f:
391
- frontmatter.dump(post, f)
392
-
393
- logger.info("VaultWriter: tracked open question → %s", staging_path)
394
-
395
-
396
238
  # ---------------------------------------------------------------------------
397
239
  # Public feed maintenance
398
240
  # ---------------------------------------------------------------------------
@@ -501,7 +343,7 @@ def append_daily_discussion(content: str) -> None:
501
343
  try:
502
344
  post = frontmatter.load(staging_path)
503
345
  post.metadata["modified_at"] = now_utc
504
- with staging_path.open("wb") as f:
346
+ with staging_path.open("w", encoding="utf-8") as f:
505
347
  frontmatter.dump(post, f)
506
348
  except Exception as exc:
507
349
  logger.warning("VaultWriter: could not update modified_at for %s: %s", filename, exc)
@@ -724,6 +566,18 @@ def _analysis_to_feed_entry(analysis: PaperAnalysis) -> dict:
724
566
  "url": analysis.url,
725
567
  "domain": analysis.domain,
726
568
  "tags": analysis.tags,
569
+ "concepts": [
570
+ {"name": getattr(c, "slug", getattr(c, "name", "")), "description": getattr(c, "one_liner", getattr(c, "description", ""))}
571
+ for c in analysis.concepts
572
+ ],
573
+ "datasets": [
574
+ {"name": getattr(d, "name", ""), "description": getattr(d, "description", "")}
575
+ for d in analysis.datasets
576
+ ],
577
+ "open_questions": [
578
+ {"name": getattr(q, "slug", getattr(q, "name", "")), "description": getattr(q, "description", "")}
579
+ for q in analysis.open_questions
580
+ ],
727
581
  "summary": analysis.summary,
728
582
  "processed_at": datetime.now(tz=timezone.utc).isoformat(),
729
583
  }
File without changes