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.
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/PKG-INFO +1 -1
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/pyproject.toml +1 -1
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/analyst.py +69 -29
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/critic.py +6 -12
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/vault_manager.py +37 -4
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/vault_writer.py +52 -198
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/README.md +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/__init__.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/config.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/discussant.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/domain_expert.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/federation.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/main.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/__init__.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/arxiv.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/base.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/biorxiv.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/common.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/openalex.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/paper_search/semantic_scholar.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/pdf_converter.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/router.py +0 -0
- {swarm_notes-0.2.0 → swarm_notes-0.3.0}/src/swarm_notes/watcher.py +0 -0
|
@@ -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
|
|
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
|
|
128
|
-
"""
|
|
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[
|
|
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
|
|
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
|
|
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.
|
|
250
|
-
11.
|
|
251
|
-
12.
|
|
252
|
-
13.
|
|
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,
|
|
308
|
+
agent: Agent[None, DynamicPaperAnalysis] = Agent(
|
|
279
309
|
model=settings.llm_model,
|
|
280
|
-
output_type=
|
|
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
|
-
|
|
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
|
|
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[
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
90
|
-
"""Return a list of all
|
|
91
|
-
if not settings.
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
46
|
-
{
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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 =
|
|
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("
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|