Skip to content

Commit 8d2ddff

Browse files
authored
Tavily news source (#42)
* tavily news * feat(sources): add tavily news source * fix(tavily-news): log unexpected exceptions; drop stale docstring numbers * wiring: opt-in tavily-news source plus --only-source filter * plan: mark Task 2 complete * polish(tavily-news): drop unused gate field from _SourceSpec * polish(tavily-news): idiomatic pass * polish(tavily-news): cache YAML, dedupe urlparse, type _SourceSpec by class * polish(tavily-news): strip and humanize comments * cleanups and back * trims
1 parent c09adba commit 8d2ddff

18 files changed

Lines changed: 2991 additions & 4 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,6 @@ slopmortem.local.toml
112112
tests/fixtures/cassettes/*.live.yaml
113113
post_mortems/
114114
journal.sqlite
115+
backups/
115116

116117
.tldextract-cache/

docs/plans/2026-05-06-tavily-news-source.md

Lines changed: 1397 additions & 0 deletions
Large diffs are not rendered by default.

docs/specs/2026-05-06-tavily-news-source-design.md

Lines changed: 578 additions & 0 deletions
Large diffs are not rendered by default.

flake.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
git
108108
git-lfs
109109
curl
110+
zip
110111
];
111112

112113
env = {

justfile

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,39 @@ init-env:
163163
164164
echo "wrote $ENV_FILE"
165165
166+
# Snapshot ingested state into a single zip under backups/. Stops Qdrant
167+
# briefly (only if it's running) so the storage volume is copied at rest,
168+
# then restarts it. Captures data/qdrant/, journal.sqlite, and post_mortems/
169+
# — the same surface `just nuke` wipes.
170+
backup:
171+
#!/usr/bin/env bash
172+
set -euo pipefail
173+
mkdir -p backups
174+
stamp=$(date +%Y%m%d-%H%M%S)
175+
out="backups/slopmortem-backup-${stamp}.zip"
176+
177+
qdrant_running=0
178+
if docker compose ps --status running --services 2>/dev/null | grep -qx qdrant; then
179+
qdrant_running=1
180+
echo "→ stopping qdrant for a consistent snapshot"
181+
docker compose stop qdrant >/dev/null
182+
fi
183+
184+
trap '[ "$qdrant_running" = "1" ] && docker compose start qdrant >/dev/null || true' EXIT
185+
186+
targets=()
187+
[ -d data/qdrant ] && targets+=(data/qdrant)
188+
[ -f journal.sqlite ] && targets+=(journal.sqlite)
189+
[ -d post_mortems ] && targets+=(post_mortems)
190+
191+
if [ ${#targets[@]} -eq 0 ]; then
192+
echo "nothing to back up (no qdrant, journal, or post_mortems present)"
193+
exit 0
194+
fi
195+
196+
zip -qr "$out" "${targets[@]}"
197+
echo "wrote $out ($(du -h "$out" | cut -f1))"
198+
166199
# Wipe all ingested state: stop Qdrant, delete its storage volume, drop
167200
# the merge journal, and remove the post_mortems tree. Prompts before
168201
# touching anything. Run before a fresh `just ingest` when you want to

slopmortem/cli/_ingest_cmd.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111

1212
import contextlib
1313
import functools
14+
import os
1415
import sys
16+
from dataclasses import dataclass
1517
from pathlib import Path
1618
from typing import TYPE_CHECKING, Annotated, cast
1719

@@ -38,6 +40,7 @@
3840
CuratedSource,
3941
HNAlgoliaSource,
4042
TavilyEnricher,
43+
TavilyNewsSource,
4144
WaybackEnricher,
4245
)
4346
from slopmortem.ingest import INGEST_PHASE_LABELS, IngestPhase, IngestResult, ingest
@@ -97,6 +100,58 @@ def ingest_cmd( # noqa: PLR0913 - every flag mirrors the spec; user types kwarg
97100
tavily_enrich: Annotated[
98101
bool, typer.Option("--tavily-enrich", help="Enable the Tavily enricher.")
99102
] = False,
103+
enable_tavily_news: Annotated[
104+
bool,
105+
typer.Option(
106+
"--enable-tavily-news",
107+
help=(
108+
"Enable the Tavily news shutdown-event source. "
109+
"Requires TAVILY_API_KEY. Bodies are returned inline; "
110+
"no Tavily-extract hop is implied."
111+
),
112+
),
113+
] = False,
114+
tavily_news_start_year: Annotated[
115+
int | None,
116+
typer.Option(
117+
"--tavily-news-start-year",
118+
help="Override year_range.start for the Tavily news source.",
119+
),
120+
] = None,
121+
tavily_news_end_year: Annotated[
122+
int | None,
123+
typer.Option(
124+
"--tavily-news-end-year",
125+
help="Override year_range.end for the Tavily news source. Defaults to current year.",
126+
),
127+
] = None,
128+
tavily_news_max_emit: Annotated[
129+
int | None,
130+
typer.Option(
131+
"--tavily-news-max-emit",
132+
help="Override the Tavily news source's max_emit cap.",
133+
),
134+
] = None,
135+
tavily_news_search_depth: Annotated[
136+
str | None,
137+
typer.Option(
138+
"--tavily-news-search-depth",
139+
help=(
140+
"Override search_depth for the Tavily news source: "
141+
"basic (1 credit) or advanced (2)."
142+
),
143+
),
144+
] = None,
145+
only_source: Annotated[
146+
str | None,
147+
typer.Option(
148+
"--only-source",
149+
help=(
150+
"Run only the named source, auto-enabling its --enable-* flag if any. "
151+
"Accepts source identifiers (curated, hn_algolia, crunchbase_csv, tavily_news)."
152+
),
153+
),
154+
] = None,
100155
post_mortems_root: Annotated[
101156
Path,
102157
typer.Option(
@@ -128,6 +183,12 @@ def ingest_cmd( # noqa: PLR0913 - every flag mirrors the spec; user types kwarg
128183
crunchbase_csv=crunchbase_csv,
129184
enrich_wayback=enrich_wayback,
130185
tavily_enrich=tavily_enrich,
186+
enable_tavily_news=enable_tavily_news,
187+
tavily_news_start_year=tavily_news_start_year,
188+
tavily_news_end_year=tavily_news_end_year,
189+
tavily_news_max_emit=tavily_news_max_emit,
190+
tavily_news_search_depth=tavily_news_search_depth,
191+
only_source=only_source,
131192
post_mortems_root=post_mortems_root,
132193
limit=limit,
133194
)
@@ -176,7 +237,7 @@ async def _run_reconcile(config: Config, post_mortems_root: Path) -> None:
176237

177238

178239
@observe(name="cli.ingest")
179-
async def _run_ingest( # noqa: PLR0913, C901 - the ingest CLI surface is wide.
240+
async def _run_ingest( # noqa: PLR0913, PLR0912, PLR0915, C901 - the ingest CLI surface is wide.
180241
*,
181242
dry_run: bool,
182243
force: bool,
@@ -187,6 +248,12 @@ async def _run_ingest( # noqa: PLR0913, C901 - the ingest CLI surface is wide.
187248
crunchbase_csv: Path | None,
188249
enrich_wayback: bool,
189250
tavily_enrich: bool,
251+
enable_tavily_news: bool,
252+
tavily_news_start_year: int | None,
253+
tavily_news_end_year: int | None,
254+
tavily_news_max_emit: int | None,
255+
tavily_news_search_depth: str | None,
256+
only_source: str | None,
190257
post_mortems_root: Path,
191258
) -> None:
192259
config = load_config()
@@ -214,6 +281,30 @@ async def _run_ingest( # noqa: PLR0913, C901 - the ingest CLI surface is wide.
214281
await _run_reconcile(config, post_mortems_root)
215282
return
216283

284+
if only_source is not None:
285+
if only_source not in _SOURCE_REGISTRY:
286+
valid = ", ".join(sorted(_SOURCE_REGISTRY))
287+
msg = f"--only-source: unknown source {only_source!r}. Valid: {valid}."
288+
raise typer.BadParameter(msg)
289+
spec = _SOURCE_REGISTRY[only_source]
290+
# Each opt-in source's --enable-* flag needs an explicit branch here —
291+
# Python's keyword-only parameter binding can't be table-driven without
292+
# ``locals()`` tricks. Add one when introducing a new opt-in source.
293+
if spec.source_class is TavilyNewsSource:
294+
enable_tavily_news = True
295+
# crunchbase_csv is gated by a path argument, not a boolean — require it explicitly.
296+
if spec.source_class is CrunchbaseCsvSource and crunchbase_csv is None:
297+
msg = "--only-source crunchbase_csv requires --crunchbase-csv PATH."
298+
raise typer.BadParameter(msg)
299+
300+
if enable_tavily_news and not os.environ.get("TAVILY_API_KEY"):
301+
msg = (
302+
"--enable-tavily-news requires TAVILY_API_KEY: the Tavily news source "
303+
"calls /search and pulls article bodies via include_raw_content. "
304+
"Set TAVILY_API_KEY in .env or unset --enable-tavily-news."
305+
)
306+
raise typer.BadParameter(msg)
307+
217308
llm, embedder, corpus, budget, journal, classifier = await _build_ingest_deps(
218309
config, post_mortems_root, dry_run=dry_run
219310
)
@@ -227,6 +318,25 @@ async def _run_ingest( # noqa: PLR0913, C901 - the ingest CLI surface is wide.
227318
]
228319
if crunchbase_csv is not None:
229320
sources.append(CrunchbaseCsvSource(csv_path=crunchbase_csv))
321+
if enable_tavily_news:
322+
sources.append(
323+
TavilyNewsSource(
324+
start_year=tavily_news_start_year,
325+
end_year=tavily_news_end_year,
326+
max_emit=tavily_news_max_emit,
327+
search_depth=tavily_news_search_depth,
328+
)
329+
)
330+
331+
if only_source is not None:
332+
wanted_class = _SOURCE_REGISTRY[only_source].source_class
333+
sources = [s for s in sources if isinstance(s, wanted_class)]
334+
if not sources:
335+
msg = (
336+
f"--only-source {only_source!r}: source enabled but not constructed; "
337+
"check that its prerequisites (e.g. --crunchbase-csv path) are present."
338+
)
339+
raise typer.BadParameter(msg)
230340

231341
enrichers: list[Enricher] = []
232342
if enrich_wayback:
@@ -277,6 +387,19 @@ async def _run_ingest( # noqa: PLR0913, C901 - the ingest CLI surface is wide.
277387
typer.echo(f"slopmortem ingest result: {result} cost=${budget.spent_usd:.4f}")
278388

279389

390+
@dataclass(frozen=True)
391+
class _SourceSpec:
392+
source_class: type[Source]
393+
394+
395+
_SOURCE_REGISTRY: dict[str, _SourceSpec] = {
396+
"curated": _SourceSpec(source_class=CuratedSource),
397+
"hn_algolia": _SourceSpec(source_class=HNAlgoliaSource),
398+
"crunchbase_csv": _SourceSpec(source_class=CrunchbaseCsvSource),
399+
"tavily_news": _SourceSpec(source_class=TavilyNewsSource),
400+
}
401+
402+
280403
def _default_curated_yaml() -> Path:
281404
return Path(__file__).parent.parent / "corpus" / "sources" / "curated" / "post_mortems_v0.yml"
282405

slopmortem/corpus/sources/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from slopmortem.corpus.sources.curated import CuratedSource as CuratedSource
99
from slopmortem.corpus.sources.hn_algolia import HNAlgoliaSource as HNAlgoliaSource
1010
from slopmortem.corpus.sources.tavily import TavilyEnricher as TavilyEnricher
11+
from slopmortem.corpus.sources.tavily_news import TavilyNewsSource as TavilyNewsSource
1112
from slopmortem.corpus.sources.wayback import WaybackEnricher as WaybackEnricher
1213

1314
__all__ = [
@@ -17,5 +18,6 @@
1718
"HNAlgoliaSource",
1819
"Source",
1920
"TavilyEnricher",
21+
"TavilyNewsSource",
2022
"WaybackEnricher",
2123
]

slopmortem/corpus/sources/_names.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
SOURCE_CURATED: Final = "curated"
88
SOURCE_HN_ALGOLIA: Final = "hn_algolia"
99
SOURCE_CRUNCHBASE_CSV: Final = "crunchbase_csv"
10+
SOURCE_TAVILY_NEWS: Final = "tavily_news"
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Aggregator / syndicated-content rehosts. Suffix match: a host matches when
2+
# it equals the listed domain or ends with `.<domain>`. Match is host-only
3+
# and case-insensitive (canonicalisation lowercases hosts upstream).
4+
#
5+
# Seed list intentionally small. Easy to grow if filler reappears at scores
6+
# above min_score.
7+
- bundle.app # observed mirroring TechCrunch in 2026-05-06 probe
8+
- flipboard.com
9+
- feedly.com
10+
- smartnews.com
11+
- inoreader.com
12+
- news.google.com

slopmortem/corpus/sources/queries/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)