Skip to content

Commit d5ebdff

Browse files
committed
Write reading reports into Obsidian layout
1 parent 9d59489 commit d5ebdff

2 files changed

Lines changed: 292 additions & 2 deletions

File tree

agents/reading-agent/main.py

Lines changed: 233 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@ def _resolve_configured_dir(
140140
category=category,
141141
project_root=PROJECT_ROOT,
142142
)
143+
obsidian_dir = _obsidian_month_dir(base_dir, paper or {}, category)
144+
if obsidian_dir is not None:
145+
obsidian_dir.mkdir(parents=True, exist_ok=True)
146+
return obsidian_dir.resolve()
143147
if _env_flag("PAPERFLOW_STORAGE_MONTHLY_SUBDIR", default=True):
144148
base_dir = base_dir / _month_folder_name(paper or {})
145149
base_dir.mkdir(parents=True, exist_ok=True)
@@ -169,6 +173,50 @@ def _month_folder_name(paper: Dict[str, Any]) -> str:
169173
return f"arXiv - {MONTH_LABELS.get(date.month, date.strftime('%b'))} {date.year}"
170174

171175

176+
def _daily_note_folder_name(paper: Dict[str, Any]) -> str:
177+
date = _parse_publish_month(paper)
178+
return f"Daily Note {date.year}"
179+
180+
181+
def _daily_note_file_name(paper: Dict[str, Any]) -> str:
182+
date = _parse_publish_month(paper)
183+
return f"Daily Note - {MONTH_LABELS.get(date.month, date.strftime('%b'))} {date.year}.md"
184+
185+
186+
def _deep_reading_folder_name(paper: Dict[str, Any]) -> str:
187+
date = _parse_publish_month(paper)
188+
return f"Deep Reading - {MONTH_LABELS.get(date.month, date.strftime('%b'))} {date.year}"
189+
190+
191+
def _looks_like_obsidian_daily_root(path: Path) -> bool:
192+
name = path.name
193+
if name == "Daily Note":
194+
return True
195+
if re.fullmatch(r"Daily Note 20\d{2}", name):
196+
return True
197+
return any(path.glob("Daily Note 20[0-9][0-9]")) or any(path.glob("Daily Note - * 20[0-9][0-9].md"))
198+
199+
200+
def _obsidian_year_dir(base_dir: Path, paper: Dict[str, Any]) -> Optional[Path]:
201+
if not _looks_like_obsidian_daily_root(base_dir):
202+
return None
203+
year_dir_name = _daily_note_folder_name(paper)
204+
if re.fullmatch(r"Daily Note 20\d{2}", base_dir.name):
205+
return base_dir
206+
return base_dir / year_dir_name
207+
208+
209+
def _obsidian_month_dir(base_dir: Path, paper: Dict[str, Any], kind: str) -> Optional[Path]:
210+
year_dir = _obsidian_year_dir(base_dir, paper)
211+
if year_dir is None:
212+
return None
213+
if kind == "pdf":
214+
return year_dir / _month_folder_name(paper)
215+
if kind == "reading_reports":
216+
return year_dir / _deep_reading_folder_name(paper)
217+
return None
218+
219+
172220
def _safe_filename(value: Any, *, max_len: int = 120) -> str:
173221
text = _clean_text(value)
174222
text = re.sub(r"[\\/:*?\"<>|]+", "-", text)
@@ -191,6 +239,13 @@ def _paper_file_stem(paper: Dict[str, Any]) -> str:
191239
return _safe_filename(paper_id or "paper")
192240

193241

242+
def _paper_title_file_stem(paper: Dict[str, Any], *, max_len: int = 96) -> str:
243+
title = _clean_text(paper.get("title"))
244+
if title:
245+
return _safe_filename(title, max_len=max_len)
246+
return _paper_file_stem(paper)
247+
248+
194249
def _wiki_ingest_enabled() -> bool:
195250
return os.environ.get("PAPERFLOW_WIKI_INGEST", "1").strip().lower() not in {
196251
"0",
@@ -1757,6 +1812,124 @@ def _persist_pdf_file(
17571812
return str(target_path)
17581813

17591814

1815+
def _obsidian_daily_note_path(report_path: Path, paper: Dict[str, Any]) -> Optional[Path]:
1816+
deep_dir = report_path.parent
1817+
year_dir = deep_dir.parent
1818+
if not deep_dir.name.startswith("Deep Reading - ") or not re.fullmatch(r"Daily Note 20\d{2}", year_dir.name):
1819+
return None
1820+
return year_dir / _daily_note_file_name(paper)
1821+
1822+
1823+
def _obsidian_note_link(report_path: Path) -> str:
1824+
year_dir = report_path.parent.parent
1825+
try:
1826+
relative = report_path.relative_to(year_dir).with_suffix("")
1827+
except ValueError:
1828+
relative = report_path.with_suffix("")
1829+
return str(relative).replace(os.sep, "/")
1830+
1831+
1832+
def _daily_note_category(paper: Dict[str, Any], report_payload: Dict[str, Any]) -> str:
1833+
values: List[str] = []
1834+
for key in ("title", "abstract", "summary", "venue", "source"):
1835+
values.append(_clean_text(paper.get(key)))
1836+
for value in paper.get("subjects") or paper.get("categories") or []:
1837+
values.append(_clean_text(value))
1838+
for value in report_payload.get("keywords") or []:
1839+
values.append(_clean_text(value))
1840+
text = " ".join(value for value in values if value).lower()
1841+
if any(term in text for term in ("education", "classroom", "student", "teacher", "learning path", "k-12", "school")):
1842+
return "AI for Education"
1843+
if any(term in text for term in ("protein", "molecular", "molecule", "biology", "bio", "chemistry", "materials science", "scientific discovery", "ai for science")):
1844+
return "AI for Science"
1845+
if any(term in text for term in ("agent", "multi-agent", "tool", "orchestration")):
1846+
return "AI Agents"
1847+
if any(term in text for term in ("vision", "image", "video", "3d", "segmentation")):
1848+
return "Computer Vision"
1849+
if any(term in text for term in ("language", "llm", "nlp", "retrieval", "rag", "reasoning")):
1850+
return "Language Models"
1851+
if any(term in text for term in ("reinforcement", "diffusion", "learning", "optimization")):
1852+
return "Machine Learning"
1853+
return "AI Research"
1854+
1855+
1856+
def _daily_note_entry(
1857+
*,
1858+
paper: Dict[str, Any],
1859+
report_payload: Dict[str, Any],
1860+
report_path: Path,
1861+
) -> str:
1862+
title = _clean_text(paper.get("title")) or report_path.stem
1863+
marker_key = _clean_text(paper.get("arxiv_id") or paper.get("doi") or title)
1864+
marker = f"<!-- paperflow:{hashlib.sha1(marker_key.encode('utf-8')).hexdigest()[:16]} -->"
1865+
note_link = _obsidian_note_link(report_path)
1866+
pdf_url = _clean_text(paper.get("pdf_url")) or _get_direct_pdf_url(paper)
1867+
summary = _clean_text(
1868+
report_payload.get("one_sentence_summary")
1869+
or report_payload.get("clean_abstract_summary")
1870+
or paper.get("abstract")
1871+
)
1872+
if len(summary) > 900:
1873+
summary = summary[:900].rstrip() + "..."
1874+
star = "⭐" if str(report_payload.get("recommendation_label") or "").lower() in {"highly_recommended", "must_read", "high_match"} else ""
1875+
lines = [
1876+
marker,
1877+
f"## {star}{title}".rstrip(),
1878+
"",
1879+
f"[[{note_link}|Deep Reading]]",
1880+
]
1881+
if pdf_url:
1882+
lines.extend(["", f"[{pdf_url}]({pdf_url})"])
1883+
if summary:
1884+
lines.extend(["", f"- **{summary}**"])
1885+
return "\n".join(lines).rstrip() + "\n"
1886+
1887+
1888+
def _sync_obsidian_daily_note(
1889+
*,
1890+
paper: Dict[str, Any],
1891+
report_payload: Dict[str, Any],
1892+
report_path: Path,
1893+
) -> Optional[str]:
1894+
daily_note = _obsidian_daily_note_path(report_path, paper)
1895+
if daily_note is None:
1896+
return None
1897+
daily_note.parent.mkdir(parents=True, exist_ok=True)
1898+
category = _daily_note_category(paper, report_payload)
1899+
entry = _daily_note_entry(paper=paper, report_payload=report_payload, report_path=report_path)
1900+
marker = entry.splitlines()[0]
1901+
current = daily_note.read_text(encoding="utf-8") if daily_note.exists() else ""
1902+
if marker in current:
1903+
return str(daily_note)
1904+
section_heading = f"# {category}"
1905+
if section_heading not in current:
1906+
current = current.rstrip() + ("\n\n" if current.strip() else "") + section_heading + "\n"
1907+
insert_at = current.find(section_heading) + len(section_heading)
1908+
next_section = current.find("\n# ", insert_at)
1909+
if next_section == -1:
1910+
updated = current.rstrip() + "\n\n" + entry
1911+
else:
1912+
updated = current[:next_section].rstrip() + "\n\n" + entry + "\n" + current[next_section:].lstrip("\n")
1913+
daily_note.write_text(updated.rstrip() + "\n", encoding="utf-8")
1914+
_sync_obsidian_toc(daily_note)
1915+
return str(daily_note)
1916+
1917+
1918+
def _sync_obsidian_toc(daily_note: Path) -> None:
1919+
year_dir = daily_note.parent
1920+
match = re.fullmatch(r"Daily Note - ([A-Za-z]+) (20\d{2})\.md", daily_note.name)
1921+
if not match:
1922+
return
1923+
month_label, year = match.groups()
1924+
toc = year_dir / f"Table of Content - {year}.md"
1925+
link_name = daily_note.stem
1926+
entry = f"# {link_name}\n[[{link_name}]]"
1927+
current = toc.read_text(encoding="utf-8") if toc.exists() else ""
1928+
if f"[[{link_name}]]" in current:
1929+
return
1930+
toc.write_text(current.rstrip() + ("\n" if current.strip() else "") + entry + "\n", encoding="utf-8")
1931+
1932+
17601933
def _save_reading_report_markdown(
17611934
*,
17621935
user_id: str,
@@ -1773,7 +1946,13 @@ def _save_reading_report_markdown(
17731946
user_id=user_id,
17741947
category="reading_reports",
17751948
)
1776-
report_path = target_dir / f"{_paper_file_stem(paper)} - reading-report.md"
1949+
obsidian_report = target_dir.name.startswith("Deep Reading - ")
1950+
report_path = target_dir / (
1951+
f"{_paper_title_file_stem(paper)}.md"
1952+
if obsidian_report
1953+
else f"{_paper_file_stem(paper)} - reading-report.md"
1954+
)
1955+
daily_note_path = _obsidian_daily_note_path(report_path, paper) if obsidian_report else None
17771956
metadata = {
17781957
"user_id": user_id,
17791958
"paper_id": paper.get("id"),
@@ -1788,14 +1967,31 @@ def _save_reading_report_markdown(
17881967
"generation_provider": report_payload.get("generation_provider"),
17891968
"generation_model": report_payload.get("generation_model"),
17901969
"report_version": READING_REPORT_OUTPUT_VERSION,
1970+
"daily_note_path": str(daily_note_path) if daily_note_path else None,
17911971
"saved_at": datetime.now().isoformat(timespec="seconds"),
17921972
}
17931973
frontmatter = ["---"]
17941974
for key, value in metadata.items():
17951975
if value not in (None, "", [], {}):
17961976
frontmatter.append(f"{key}: {json.dumps(value, ensure_ascii=False)}")
17971977
frontmatter.extend(["---", ""])
1798-
report_path.write_text("\n".join(frontmatter) + report_content.strip() + "\n", encoding="utf-8")
1978+
obsidian_header = ""
1979+
if obsidian_report and daily_note_path:
1980+
daily_note_link = daily_note_path.with_suffix("").name
1981+
pdf_url = _clean_text(paper.get("pdf_url")) or _get_direct_pdf_url(paper)
1982+
pdf_line = f"- PDF:[{pdf_url}]({pdf_url})\n" if pdf_url else ""
1983+
obsidian_header = f"[[{daily_note_link}]]\n\n{pdf_line}\n"
1984+
report_path.write_text(
1985+
"\n".join(frontmatter) + obsidian_header + report_content.strip() + "\n",
1986+
encoding="utf-8",
1987+
)
1988+
daily_note_written = _sync_obsidian_daily_note(
1989+
paper=paper,
1990+
report_payload=report_payload,
1991+
report_path=report_path,
1992+
)
1993+
if daily_note_written:
1994+
print(f" Updated Daily Note: {daily_note_written}")
17991995
print(f" Saved reading markdown: {report_path}")
18001996
return str(report_path)
18011997

@@ -1891,6 +2087,41 @@ def enrich_paper_for_reading_report(
18912087
)
18922088
if pdf_url and not should_parse_pdf:
18932089
print(f" Skipping PDF enrichment ({skip_reason})")
2090+
if _env_flag("PAPERFLOW_SAVE_PDF_WITHOUT_ENRICHMENT", default=True):
2091+
download_candidates = _build_pdf_download_candidates(enriched, pdf_url) or [pdf_url]
2092+
stored_pdf_path: Optional[str] = None
2093+
try:
2094+
for download_candidate in download_candidates:
2095+
try:
2096+
download_referer = _pick_download_referer(enriched, download_candidate)
2097+
print(f" Downloading PDF for storage: {download_candidate[:120]}")
2098+
temp_pdf_path = _download_pdf(
2099+
download_candidate,
2100+
enriched.get("title", "paper"),
2101+
referer=download_referer,
2102+
)
2103+
enriched["pdf_url"] = download_candidate
2104+
stored_pdf_path = _persist_pdf_file(
2105+
temp_pdf_path,
2106+
enriched,
2107+
download_candidate,
2108+
user_id=user_id,
2109+
)
2110+
if stored_pdf_path:
2111+
enriched["pdf_path"] = stored_pdf_path
2112+
break
2113+
except Exception as exc:
2114+
pdf_error = str(exc)
2115+
print(f" PDF storage download failed ({download_candidate[:120]}): {exc}")
2116+
continue
2117+
finally:
2118+
if temp_pdf_path:
2119+
try:
2120+
temp_path = Path(temp_pdf_path)
2121+
if not stored_pdf_path or temp_path.resolve() != Path(stored_pdf_path).resolve():
2122+
temp_path.unlink(missing_ok=True)
2123+
except OSError:
2124+
pass
18942125

18952126
if pdf_url and should_parse_pdf:
18962127
download_candidates = _build_pdf_download_candidates(enriched, pdf_url) or [pdf_url]

tests/test_desktop_gui.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import threading
44
import time
5+
import importlib
56
from datetime import timedelta
67
from pathlib import Path
78

@@ -10,6 +11,8 @@
1011
from deployments.desktop import server
1112
from deployments.desktop.shared import agents
1213

14+
reading_agent = importlib.import_module("agents.reading-agent.main")
15+
1316
PROJECT_ROOT = Path(__file__).resolve().parents[1]
1417

1518

@@ -2144,3 +2147,59 @@ def test_desktop_reports_payload_warns_on_feishu_failure() -> None:
21442147
assert payload["write_feishu_requested"] is True
21452148
assert "飞书文档发送失败" in payload["feishu_warning"]
21462149
assert payload["created_docs"][0]["feishu_error"] == "missing feishu config"
2150+
2151+
2152+
def test_reading_report_writes_obsidian_deep_reading_and_daily_note(
2153+
tmp_path, monkeypatch: pytest.MonkeyPatch
2154+
) -> None:
2155+
daily_root = tmp_path / "Daily Note"
2156+
monkeypatch.setenv("PAPERFLOW_READING_REPORTS_DIR", str(daily_root))
2157+
monkeypatch.setenv("PAPERFLOW_PDF_DIR", str(daily_root))
2158+
2159+
paper = {
2160+
"id": 1,
2161+
"title": "Generative AI in K-12 Classrooms: A Midyear Implementation Report",
2162+
"arxiv_id": "2605.16277",
2163+
"publish_date": "2026-05-20",
2164+
"pdf_url": "https://arxiv.org/pdf/2605.16277",
2165+
"subjects": ["cs.AI"],
2166+
"abstract": "This paper studies generative AI in classrooms with teachers and students.",
2167+
}
2168+
payload = {
2169+
"one_sentence_summary": "生成式人工智能在K-12教育场景中的实际应用模式与早期学业信号。",
2170+
"keywords": ["education", "classroom"],
2171+
"generation_provider": "heuristic",
2172+
"generation_model": "test",
2173+
"recommendation_label": "high_match",
2174+
}
2175+
2176+
report_path = Path(
2177+
reading_agent._save_reading_report_markdown( # noqa: SLF001 - storage contract
2178+
user_id="cheng tan",
2179+
paper=paper,
2180+
report_content="# Generative AI in K-12 Classrooms\n\n## 一句话总结\n\n测试报告。",
2181+
report_payload=payload,
2182+
)
2183+
)
2184+
2185+
expected_dir = daily_root / "Daily Note 2026" / "Deep Reading - May 2026"
2186+
daily_note = daily_root / "Daily Note 2026" / "Daily Note - May 2026.md"
2187+
toc = daily_root / "Daily Note 2026" / "Table of Content - 2026.md"
2188+
pdf_dir = reading_agent._resolve_configured_dir( # noqa: SLF001 - storage contract
2189+
"PAPERFLOW_PDF_DIR",
2190+
"data/exports",
2191+
paper,
2192+
user_id="cheng tan",
2193+
category="pdf",
2194+
)
2195+
2196+
assert report_path.parent == expected_dir
2197+
assert report_path.name.startswith("Generative AI in K-12 Classrooms")
2198+
assert report_path.suffix == ".md"
2199+
assert "[[Daily Note - May 2026]]" in report_path.read_text(encoding="utf-8")
2200+
assert pdf_dir == (daily_root / "Daily Note 2026" / "arXiv - May 2026").resolve()
2201+
daily_text = daily_note.read_text(encoding="utf-8")
2202+
assert "# AI for Education" in daily_text
2203+
assert "[[Deep Reading - May 2026/Generative AI in K-12 Classrooms" in daily_text
2204+
assert "https://arxiv.org/pdf/2605.16277" in daily_text
2205+
assert "[[Daily Note - May 2026]]" in toc.read_text(encoding="utf-8")

0 commit comments

Comments
 (0)