feat: NotebookLM sync with date-specific sources and consolidation (#28)

This commit is contained in:
ahmet guzererler 2026-03-19 15:39:25 +01:00 committed by GitHub
parent d2e967c4fc
commit d92fd9cab1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 272 additions and 74 deletions

View File

@ -6,8 +6,6 @@ ANTHROPIC_API_KEY=
XAI_API_KEY= XAI_API_KEY=
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
NOTEBOOKLM_ID=e8fd4391-9cb2-43ff-b893-1316a52857b6
# ── Data Provider API Keys ─────────────────────────────────────────── # ── Data Provider API Keys ───────────────────────────────────────────
ALPHA_VANTAGE_API_KEY= ALPHA_VANTAGE_API_KEY=
# Free at https://finnhub.io — required for earnings/economic calendars and insider transactions # Free at https://finnhub.io — required for earnings/economic calendars and insider transactions
@ -16,8 +14,6 @@ FINNHUB_API_KEY=
TRADINGAGENTS_RESULTS_DIR=./my_results TRADINGAGENTS_RESULTS_DIR=./my_results
TRADINGAGENTS_MAX_DEBATE_ROUNDS=2 TRADINGAGENTS_MAX_DEBATE_ROUNDS=2
NOTEBOOKLM_ID=
#TRADINGAGENTS_VENDOR_SCANNER_DATA=alpha_vantage #TRADINGAGENTS_VENDOR_SCANNER_DATA=alpha_vantage
# ── Configuration overrides ────────────────────────────────────────── # ── Configuration overrides ──────────────────────────────────────────
@ -69,7 +65,7 @@ NOTEBOOKLM_ID=
# ── Google NotebookLM sync (optional) ──────────────────────────────── # ── Google NotebookLM sync (optional) ────────────────────────────────
# Notebook ID for daily digest upload via the nlm CLI tool # Notebook ID for daily digest upload via the nlm CLI tool
# NOTEBOOK_ID= # NOTEBOOKLM_ID=
# ── Data vendor routing ────────────────────────────────────────────── # ── Data vendor routing ──────────────────────────────────────────────
# Category-level vendor selection (yfinance | alpha_vantage | finnhub) # Category-level vendor selection (yfinance | alpha_vantage | finnhub)

View File

@ -1181,7 +1181,7 @@ def run_analysis():
digest_path = append_to_digest( digest_path = append_to_digest(
selections["analysis_date"], "analyze", selections["ticker"], digest_content selections["analysis_date"], "analyze", selections["ticker"], digest_content
) )
sync_to_notebooklm(digest_path) sync_to_notebooklm(digest_path, selections["analysis_date"])
# Write observability log # Write observability log
log_dir = get_ticker_dir(selections["analysis_date"], selections["ticker"]) log_dir = get_ticker_dir(selections["analysis_date"], selections["ticker"])
@ -1280,10 +1280,23 @@ def run_scan(date: Optional[str] = None):
set_run_logger(None) set_run_logger(None)
# Append to daily digest and sync to NotebookLM # Append to daily digest and sync to NotebookLM
macro_content = result.get("macro_scan_summary", "") scan_parts = []
if result.get("geopolitical_report"):
scan_parts.append(f"### Geopolitical & Macro\n{result['geopolitical_report']}")
if result.get("market_movers_report"):
scan_parts.append(f"### Market Movers\n{result['market_movers_report']}")
if result.get("sector_performance_report"):
scan_parts.append(f"### Sector Performance\n{result['sector_performance_report']}")
if result.get("industry_deep_dive_report"):
scan_parts.append(f"### Industry Deep Dive\n{result['industry_deep_dive_report']}")
if result.get("macro_scan_summary"):
scan_parts.append(f"### Macro Scan Summary\n{result['macro_scan_summary']}")
macro_content = "\n\n".join(scan_parts)
if macro_content: if macro_content:
digest_path = append_to_digest(scan_date, "scan", "Market Scan", macro_content) digest_path = append_to_digest(scan_date, "scan", "Market Scan", macro_content)
sync_to_notebooklm(digest_path) sync_to_notebooklm(digest_path, scan_date)
console.print(f"\n[green]Results saved to {save_dir}[/green]") console.print(f"\n[green]Results saved to {save_dir}[/green]")
@ -1365,6 +1378,12 @@ def run_pipeline():
) )
set_run_logger(None) set_run_logger(None)
# Append to daily digest and sync to NotebookLM
from tradingagents.pipeline.macro_bridge import render_combined_summary
pipeline_summary = render_combined_summary(results, macro_context)
digest_path = append_to_digest(analysis_date, "pipeline", "Pipeline Summary", pipeline_summary)
sync_to_notebooklm(digest_path, analysis_date)
successes = [r for r in results if not r.error] successes = [r for r in results if not r.error]
failures = [r for r in results if r.error] failures = [r for r in results if r.error]
console.print(f"\n[green]Done: {len(successes)} succeeded, {len(failures)} failed[/green]") console.print(f"\n[green]Done: {len(successes)} succeeded, {len(failures)} failed[/green]")

View File

@ -5,19 +5,17 @@ Daily digest consolidation and Google NotebookLM sync shipped (PR open: `feat/da
# Recent Progress # Recent Progress
- **PR #22 merged**: Unified report paths, structured observability logging, memory system update - **PR #22 merged**: Unified report paths, structured observability logging, memory system update
- **feat/daily-digest-notebooklm** (open PR): Daily digest consolidation + NotebookLM sync - **feat/daily-digest-notebooklm** (shipped): Daily digest consolidation + NotebookLM source sync
- `tradingagents/daily_digest.py``append_to_digest()` appends timestamped entries to `reports/daily/{date}/daily_digest.md` - `tradingagents/daily_digest.py``append_to_digest()` appends timestamped entries to `reports/daily/{date}/daily_digest.md`
- `tradingagents/notebook_sync.py``sync_to_notebooklm()` deletes old source then uploads new digest via `nlm` CLI (opt-in via `NOTEBOOK_ID` env var) - `tradingagents/notebook_sync.py``sync_to_notebooklm()` deletes existing "Daily Trading Digest" source then uploads new content via `nlm source add --text --wait`.
- `tradingagents/report_paths.py` — added `get_digest_path(date)` - `tradingagents/report_paths.py` — added `get_digest_path(date)`
- `cli/main.py``analyze` and `scan` commands both call digest + sync after each run - `cli/main.py``analyze` and `scan` commands both call digest + sync after each run
- `.env.example``NOTEBOOK_ID` added - `.env.example` — fixed consistency, removed duplicates, aligned with `NOTEBOOKLM_ID`
- **PR #21 merged**: Memory system v2 — builder/reader skills, 5 context files, post-commit hook - **Verification**: 220+ offline tests passing + 5 new unit tests for `notebook_sync.py` + live integration test passed.
- **PR #18 merged**: Opt-in vendor fallback — fail-fast by default (ADR 011)
- 220+ offline tests passing
# In Progress # In Progress
- Awaiting `NOTEBOOK_ID` from user to enable end-to-end NotebookLM test - Refinement of macro scan synthesis prompts (ongoing)
# Active Blockers # Active Blockers

View File

@ -102,12 +102,13 @@ Source: `tradingagents/report_paths.py`
## Daily Digest & NotebookLM Sync ## Daily Digest & NotebookLM Sync
After every `analyze` or `scan` run, the CLI: After every `analyze`, `scan`, or `pipeline` run, the CLI:
1. Calls `append_to_digest(date, entry_type, label, content)` → appends a timestamped section to `reports/daily/{date}/daily_digest.md` (creates the file on first run) 1. Calls `append_to_digest(date, entry_type, label, content)` → appends a timestamped section to `reports/daily/{date}/daily_digest.md` (creates the file on first run)
2. Calls `sync_to_notebooklm(digest_path)` → deletes the previous `daily_digest.md` source from the configured NotebookLM notebook, then uploads the updated file via the `nlm` CLI tool 2. Calls `sync_to_notebooklm(digest_path, date)` → finds the existing source titled `Daily Trading Digest ({date})` inside the configured NotebookLM notebook, deletes it if it exists, and then uploads the updated file content via `nlm source add --text --wait`.
`NOTEBOOK_ID` env var controls the target notebook. If unset, the sync step is silently skipped (opt-in). This ensures there is a single, up-to-date source per day in the user's NotebookLM workspace. `scan` consolidates all 5 macro reports into this digest.
`NOTEBOOKLM_ID` env var controls the target notebook. If unset, the sync step is silently skipped (opt-in).
Source: `tradingagents/daily_digest.py`, `tradingagents/notebook_sync.py` Source: `tradingagents/daily_digest.py`, `tradingagents/notebook_sync.py`
## Observability ## Observability

View File

@ -178,6 +178,8 @@ cli/
| `test_json_utils.py` | Unit | `extract_json()` — markdown, think blocks, edge cases | — | | `test_json_utils.py` | Unit | `extract_json()` — markdown, think blocks, edge cases | — |
| `test_macro_bridge.py` | Unit | Pipeline: parse, filter, render, save | — | | `test_macro_bridge.py` | Unit | Pipeline: parse, filter, render, save | — |
| `test_macro_regime.py` | Mixed | Macro signals, regime classification, report format | `integration` on live test | | `test_macro_regime.py` | Mixed | Macro signals, regime classification, report format | `integration` on live test |
| `test_nlm_live.py` | Integration | Live NLM CLI tests for NotebookLM sync | — |
| `test_notebook_sync.py` | Unit | `notebook_sync.py` logic, `nlm` subprocess mocking | — |
| `test_peer_comparison.py` | Mixed | Peer comparison functions | `integration` on live test | | `test_peer_comparison.py` | Mixed | Peer comparison functions | `integration` on live test |
| `test_scanner_comprehensive.py` | Integration | All 5 scanner tools + CLI output naming | — | | `test_scanner_comprehensive.py` | Integration | All 5 scanner tools + CLI output naming | — |
| `test_scanner_fallback.py` | Mixed | yfinance perf, AV failure mode, fallback routing | `integration` on some | | `test_scanner_fallback.py` | Mixed | yfinance perf, AV failure mode, fallback routing | `integration` on some |

View File

@ -0,0 +1,72 @@
import os
import json
import subprocess
import pytest
from pathlib import Path
# This test requires a real NOTEBOOKLM_ID in .env and nlm CLI logged in.
# It is excluded from regular unit tests by its location/filename.
NOTEBOOK_ID = os.environ.get("NOTEBOOKLM_ID")
NLM_PATH = os.path.expanduser("~/.local/bin/nlm")
@pytest.mark.skipif(not NOTEBOOK_ID, reason="NOTEBOOKLM_ID not set")
@pytest.mark.skipif(not os.path.exists(NLM_PATH), reason="nlm CLI not found")
def test_nlm_source_crud_live():
"""Live integration test for nlm source commands."""
date = "2026-03-19"
test_title = f"Integration Test Source ({date})"
test_file = Path("test_integration_source.md")
test_file.write_text("# Integration Test Content")
try:
# 1. Check if it already exists (from a failed run, maybe) and delete it
print(f"\nChecking for existing '{test_title}' source...")
result = subprocess.run(
[NLM_PATH, "source", "list", NOTEBOOK_ID, "--json"],
capture_output=True, text=True, check=True
)
sources = json.loads(result.stdout)
for s in sources:
if s.get("title") == test_title:
print(f"Deleting existing source {s['id']}")
subprocess.run([NLM_PATH, "source", "delete", NOTEBOOK_ID, s["id"], "-y"], check=False)
# 2. Add source via text to ensure title is respected
print(f"Adding source: {test_title}")
result = subprocess.run(
[NLM_PATH, "source", "add", NOTEBOOK_ID, "--text", "Integration Test Content", "--title", test_title, "--wait"],
capture_output=True, text=True, check=True
)
assert "Added source" in result.stdout
# Parse ID from stdout if possible (it's not JSON)
import re
match = re.search(r"Source ID: ([a-f0-9\-]+)", result.stdout)
source_id = match.group(1) if match else None
assert source_id is not None
print(f"Source created with ID: {source_id}")
# 3. List and verify finding by name
print(f"Verifying we can find source by its name title: '{test_title}'")
result = subprocess.run(
[NLM_PATH, "source", "list", NOTEBOOK_ID, "--json"],
capture_output=True, text=True, check=True
)
sources = json.loads(result.stdout)
found_id_by_name = None
for s in sources:
if s.get("title") == test_title:
found_id_by_name = s.get("id")
break
assert found_id_by_name == source_id, f"Failed to find source ID {source_id} by title '{test_title}'\nFound sources: {[s.get('title') for s in sources]}"
print(f"Successfully found source {found_id_by_name} by title.")
finally:
# 4. Clean up
if 'source_id' in locals() and source_id:
print(f"Cleaning up source {source_id}")
subprocess.run([NLM_PATH, "source", "delete", NOTEBOOK_ID, source_id, "-y"], check=False)
print("Integration test complete")

View File

@ -0,0 +1,123 @@
import json
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from tradingagents.notebook_sync import sync_to_notebooklm
@pytest.fixture
def mock_nlm_path(tmp_path):
nlm = tmp_path / "nlm"
nlm.touch(mode=0o755)
return str(nlm)
def test_sync_skips_when_no_notebook_id():
"""Should return silently if NOOTEBOOKLM_ID is not set."""
with patch.dict(os.environ, {}, clear=True):
# Should not raise or call anything
sync_to_notebooklm(Path("test.md"), "2026-03-19")
def test_sync_skips_when_nlm_not_found():
"""Should warn and skip if nlm binary is not in PATH."""
with patch.dict(os.environ, {"NOTEBOOKLM_ID": "test-id"}):
with patch("shutil.which", return_value=None):
with patch("tradingagents.notebook_sync.Path.exists", return_value=False):
sync_to_notebooklm(Path("test.md"), "2026-03-19")
def test_sync_performs_delete_then_add(mock_nlm_path):
"""Should find existing source, delete it, then add new one."""
notebook_id = "test-notebook-id"
source_id = "existing-source-id"
digest_path = Path("digest.md")
content = "# Daily Digest"
# Mock file reading
with patch.object(Path, "read_text", return_value=content):
with patch.dict(os.environ, {"NOTEBOOKLM_ID": notebook_id}):
with patch("shutil.which", return_value=mock_nlm_path):
with patch("subprocess.run") as mock_run:
# 1. Mock 'source list' finding an existing source
list_result = MagicMock()
list_result.returncode = 0
list_result.stdout = json.dumps([{"id": source_id, "title": "Daily Trading Digest (2026-03-19)"}])
# 2. Mock 'source delete' success
delete_result = MagicMock()
delete_result.returncode = 0
# 3. Mock 'source add' success
add_result = MagicMock()
add_result.returncode = 0
mock_run.side_effect = [list_result, delete_result, add_result]
sync_to_notebooklm(digest_path, "2026-03-19")
# Verify calls
assert mock_run.call_count == 3
# Check list call
args, kwargs = mock_run.call_args_list[0]
assert "list" in args[0]
assert notebook_id in args[0]
# Check delete call
args, kwargs = mock_run.call_args_list[1]
assert "delete" in args[0]
assert source_id in args[0]
# Check add call
args, kwargs = mock_run.call_args_list[2]
assert "add" in args[0]
assert "--text" in args[0]
assert content in args[0]
def test_sync_adds_directly_when_none_exists(mock_nlm_path):
"""Should add new source directly if no existing one is found."""
notebook_id = "test-notebook-id"
digest_path = Path("digest.md")
content = "# New Digest"
with patch.object(Path, "read_text", return_value=content):
with patch.dict(os.environ, {"NOTEBOOKLM_ID": notebook_id}):
with patch("shutil.which", return_value=mock_nlm_path):
with patch("subprocess.run") as mock_run:
# 1. Mock 'source list' returning empty list
list_result = MagicMock()
list_result.returncode = 0
list_result.stdout = "[]"
# 2. Mock 'source add' success
add_result = MagicMock()
add_result.returncode = 0
mock_run.side_effect = [list_result, add_result]
sync_to_notebooklm(digest_path, "2026-03-19")
# Verify only 2 calls (no delete)
assert mock_run.call_count == 2
assert "list" in mock_run.call_args_list[0][0][0]
assert "add" in mock_run.call_args_list[1][0][0]
def test_handles_json_error_gracefully(mock_nlm_path):
"""Should skip delete and attempt add if JSON list parsing fails."""
with patch.object(Path, "read_text", return_value="content"):
with patch.dict(os.environ, {"NOTEBOOKLM_ID": "id"}):
with patch("shutil.which", return_value=mock_nlm_path):
with patch("subprocess.run") as mock_run:
# Mock invalid JSON
list_result = MagicMock()
list_result.stdout = "invalid json"
add_result = MagicMock()
add_result.returncode = 0
mock_run.side_effect = [list_result, add_result]
sync_to_notebooklm(Path("test.md"), "2026-03-19")
assert mock_run.call_count == 2
assert "add" in mock_run.call_args_list[1][0][0]

View File

@ -17,38 +17,28 @@ from rich.console import Console
console = Console() console = Console()
_NOTE_TITLE = "Daily Trading Digest"
# Common install locations outside of PATH (e.g. pip install --user)
_FALLBACK_PATHS = [
Path.home() / ".local" / "bin" / "nlm",
Path("/usr/local/bin/nlm"),
]
def _find_nlm() -> str | None: def _find_nlm() -> str | None:
"""Return the path to the nlm binary, or None if not found.""" """Resolve the path to the nlm CLI."""
found = shutil.which("nlm") if nlm_path := shutil.which("nlm"):
if found: return nlm_path
return found
for p in _FALLBACK_PATHS:
if p.exists():
return str(p)
return None return None
def sync_to_notebooklm(digest_path: Path, notebook_id: str | None = None) -> None: def sync_to_notebooklm(digest_path: Path, date: str, notebook_id: str | None = None) -> None:
"""Upload *digest_path* content to Google NotebookLM as a note. """Upload *digest_path* content to Google NotebookLM as a source.
If a note titled ``Daily Trading Digest`` already exists it is updated If a source with the title for the given day already exists, it is deleted
in-place; otherwise a new note is created. and re-uploaded to ensure the latest content is indexed.
Parameters Parameters
---------- ----------
digest_path: digest_path:
Path to the digest markdown file to upload. Path to the digest markdown file to upload.
date:
The date string (e.g., "YYYY-MM-DD") used for the source title.
notebook_id: notebook_id:
NotebookLM notebook ID. Falls back to the ``NOTEBOOK_ID`` NotebookLM notebook ID. Falls back to the ``NOTEBOOKLM_ID``
environment variable when *None*. environment variable when *None*.
""" """
if notebook_id is None: if notebook_id is None:
@ -62,63 +52,60 @@ def sync_to_notebooklm(digest_path: Path, notebook_id: str | None = None) -> Non
return return
content = digest_path.read_text() content = digest_path.read_text()
title = f"Daily Trading Digest ({date})"
# Check for an existing note with the same title # Find and delete existing source with the same title
existing_note_id = _find_note(nlm, notebook_id) existing_source_id = _find_source(nlm, notebook_id, title)
if existing_source_id:
_delete_source(nlm, notebook_id, existing_source_id)
if existing_note_id: # Add as a new source
_update_note(nlm, notebook_id, existing_note_id, content) _add_source(nlm, notebook_id, content, title)
else:
_create_note(nlm, notebook_id, content)
def _find_note(nlm: str, notebook_id: str) -> str | None: def _find_source(nlm: str, notebook_id: str, title: str) -> str | None:
"""Return the note ID for the daily digest note, or None if not found.""" """Return the source ID for the daily digest, or None if not found."""
try: try:
result = subprocess.run( result = subprocess.run(
[nlm, "note", "list", notebook_id, "--json"], [nlm, "source", "list", notebook_id, "--json"],
capture_output=True, capture_output=True,
text=True, text=True,
) )
if result.returncode != 0: if result.returncode != 0:
return None return None
data = json.loads(result.stdout) sources = json.loads(result.stdout)
notes = data.get("notes", data) if isinstance(data, dict) else data for source in sources:
for note in notes: if isinstance(source, dict) and source.get("title") == title:
if isinstance(note, dict) and note.get("title") == _NOTE_TITLE: return source.get("id")
return note.get("id") or note.get("noteId")
except (ValueError, KeyError, OSError): except (ValueError, KeyError, OSError):
pass pass
return None return None
def _create_note(nlm: str, notebook_id: str, content: str) -> None: def _delete_source(nlm: str, notebook_id: str, source_id: str) -> None:
"""Create a new note in the notebook.""" """Delete an existing source."""
try:
subprocess.run(
[nlm, "source", "delete", notebook_id, source_id, "-y"],
capture_output=True,
text=True,
check=False, # Ignore non-zero exit since nlm sometimes fails even on success
)
except OSError:
pass
def _add_source(nlm: str, notebook_id: str, content: str, title: str) -> None:
"""Add content as a new source."""
try: try:
result = subprocess.run( result = subprocess.run(
[nlm, "note", "create", notebook_id, "--title", _NOTE_TITLE, "--content", content], [nlm, "source", "add", notebook_id, "--title", title, "--text", content, "--wait"],
capture_output=True, capture_output=True,
text=True, text=True,
) )
if result.returncode == 0: if result.returncode == 0:
console.print(f"[green]✓ Created NotebookLM note: {_NOTE_TITLE}[/green]") console.print(f"[green]✓ Synced NotebookLM source: {title}[/green]")
else: else:
console.print(f"[yellow]Warning: nlm note create failed: {result.stderr.strip()}[/yellow]") console.print(f"[yellow]Warning: nlm source add failed: {result.stderr.strip()}[/yellow]")
except OSError as exc: except OSError as exc:
console.print(f"[yellow]Warning: could not create NotebookLM note: {exc}[/yellow]") console.print(f"[yellow]Warning: could not add NotebookLM source: {exc}[/yellow]")
def _update_note(nlm: str, notebook_id: str, note_id: str, content: str) -> None:
"""Update an existing note's content."""
try:
result = subprocess.run(
[nlm, "note", "update", notebook_id, note_id, "--content", content],
capture_output=True,
text=True,
)
if result.returncode == 0:
console.print(f"[green]✓ Updated NotebookLM note: {_NOTE_TITLE}[/green]")
else:
console.print(f"[yellow]Warning: nlm note update failed: {result.stderr.strip()}[/yellow]")
except OSError as exc:
console.print(f"[yellow]Warning: could not update NotebookLM note: {exc}[/yellow]")