1111
1212import contextlib
1313import functools
14+ import os
1415import sys
16+ from dataclasses import dataclass
1517from pathlib import Path
1618from typing import TYPE_CHECKING , Annotated , cast
1719
3840 CuratedSource ,
3941 HNAlgoliaSource ,
4042 TavilyEnricher ,
43+ TavilyNewsSource ,
4144 WaybackEnricher ,
4245)
4346from 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+
280403def _default_curated_yaml () -> Path :
281404 return Path (__file__ ).parent .parent / "corpus" / "sources" / "curated" / "post_mortems_v0.yml"
282405
0 commit comments