Pasting a YouTube playlist URL into either entry point now expands into one source row per video. Single-video URLs and non-YouTube URLs keep their existing behaviour untouched. Service module additions: - is_youtube_playlist_url(url): strict detector. Only `/playlist?list=…` on a known YouTube host (youtube.com / m / music / no-www) counts. A `watch?v=…&list=…` URL is ambiguous (user usually pasted a single video that happens to sit inside a playlist) and intentionally falls through to single-add. To fan out, paste the canonical playlist URL. - expand_youtube_playlist(url, *, max_items=50): yt-dlp with extract_flat=True, playlistend=max_items, skip_download. Builds a canonical https://www.youtube.com/watch?v={id} URL per entry and silently drops placeholders for private/removed videos. - add_playlist(sess, *, url, domain, focus, max_items, expander=None): loops expansion entries through add_source so URL validation, source_type detection, and the UNIQUE dedupe path stay identical to the single-add flow. Per-entry titles win over any caller-supplied title (a single playlist title would be wrong for N videos). Partial failures don't abort the batch — failed entries are tallied with up-to-10 (url, reason) tuples for the flash. `expander` is an injection seam for tests so the suite never hits YouTube unless explicitly opted in. - DEFAULT_PLAYLIST_MAX_ITEMS = 50 — shared ceiling, no throttle change. CLI: `second-brain add <playlist-url>` auto-detects and reports `expanded / added / duplicates / failed`. No new flag needed. Web: POST /sources/add same detection. _playlist_flash() builds the HTMX flash — "Queued N videos (M duplicates skipped, F failed)" with sensible plural forms and graceful omission of zero counters. Tests: - 15 pure-Python detection cases (positives + negatives, including the ambiguous watch?v=…&list=… rule). - 3 DB-backed add_playlist tests (with a mocked expander, so no network): count aggregation across new + pre-seeded duplicates, bad-entry tolerance, and the empty-playlist case. - 1 opt-in live-network test gated on SECOND_BRAIN_LIVE_NETWORK_TESTS=1 exercising expand_youtube_playlist against a real public playlist. Live-verified end to end: - web POST of a real 13-entry public playlist queued 13 video rows with titles, flash showed "Queued 13 videos". - re-POST returned "Queued 0 videos (13 duplicates skipped)". - watch?v=…&list=… correctly stayed a single-add. - CLI parity confirmed against the same playlist.
394 lines
14 KiB
Python
394 lines
14 KiB
Python
"""
|
|
second-brain CLI entry point.
|
|
|
|
Commands:
|
|
add — queue a source URL (video or article)
|
|
process — run the full pipeline on pending/pulled/transcribed sources
|
|
serve — launch the FastAPI web UI
|
|
compile — run the wiki compiler on accepted sources
|
|
schedule — start the overnight scheduler
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from typing import Optional
|
|
|
|
import click
|
|
|
|
from second_brain.config import DOMAINS, load_config
|
|
from second_brain.database import get_database
|
|
from second_brain.models import Source, SourceStatus, SourceType, utcnow
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI group
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@click.group()
|
|
def cli() -> None:
|
|
"""second-brain — personal knowledge extraction pipeline."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# add
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@cli.command()
|
|
@click.argument("url")
|
|
@click.option(
|
|
"--domain",
|
|
"-d",
|
|
type=click.Choice(DOMAINS),
|
|
default="development",
|
|
show_default=True,
|
|
help="Knowledge domain for extraction.",
|
|
)
|
|
@click.option("--focus", "-f", default=None, help="Optional focus directive.")
|
|
@click.option("--title", "-t", default=None, help="Override title.")
|
|
def add(url: str, domain: str, focus: Optional[str], title: Optional[str]) -> None:
|
|
"""Queue a source URL for processing.
|
|
|
|
Pasting a YouTube playlist URL (the `/playlist?list=…` form) auto-
|
|
expands into one source row per video — no flag, just paste.
|
|
`watch?v=…&list=…` is treated as a single video (use the canonical
|
|
playlist URL to fan out).
|
|
"""
|
|
from second_brain.sources_service import (
|
|
DEFAULT_PLAYLIST_MAX_ITEMS,
|
|
add_playlist,
|
|
add_source,
|
|
is_youtube_playlist_url,
|
|
)
|
|
|
|
config = load_config()
|
|
config.ensure_dirs()
|
|
db = get_database()
|
|
|
|
# --- playlist fan-out path ---------------------------------------------
|
|
if is_youtube_playlist_url(url):
|
|
try:
|
|
with db.session() as sess:
|
|
result = add_playlist(
|
|
sess,
|
|
url=url,
|
|
domain=domain,
|
|
focus=focus,
|
|
max_items=DEFAULT_PLAYLIST_MAX_ITEMS,
|
|
)
|
|
except ValueError as exc:
|
|
click.echo(f"[add] Error: {exc}", err=True)
|
|
sys.exit(2)
|
|
|
|
click.echo(
|
|
f"[add] Playlist expanded: {result.expanded} entr"
|
|
f"{'y' if result.expanded == 1 else 'ies'} "
|
|
f"(cap {DEFAULT_PLAYLIST_MAX_ITEMS})"
|
|
)
|
|
click.echo(
|
|
f" added={result.added} duplicates={result.duplicates} "
|
|
f"failed={result.failed}"
|
|
)
|
|
for u, why in result.failures[:5]:
|
|
click.echo(f" ! {u}: {why}", err=True)
|
|
return
|
|
|
|
# --- single-source path ------------------------------------------------
|
|
try:
|
|
with db.session() as sess:
|
|
result = add_source(
|
|
sess, url=url, domain=domain, focus=focus, title=title
|
|
)
|
|
# Capture display fields inside the session — `result.source`
|
|
# detaches on commit and accessing it later would error.
|
|
source_id = result.source.id
|
|
source_url = result.source.url
|
|
source_type_value = result.source.source_type.value
|
|
existing_status = result.source.status.value
|
|
added = result.added
|
|
except ValueError as exc:
|
|
click.echo(f"[add] Error: {exc}", err=True)
|
|
sys.exit(2)
|
|
|
|
if not added:
|
|
click.echo(f"[add] Already queued (id={source_id}, status={existing_status})")
|
|
return
|
|
|
|
click.echo(f"[add] Queued {source_type_value}: {source_url}")
|
|
click.echo(f" domain={domain}" + (f" focus={focus}" if focus else ""))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# process
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@cli.command()
|
|
@click.option("--limit", "-n", default=None, type=int, help="Max sources to process.")
|
|
@click.option(
|
|
"--skip-pull", is_flag=True, default=False, help="Skip the pull/download step."
|
|
)
|
|
@click.option(
|
|
"--skip-transcribe",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Skip the transcription step.",
|
|
)
|
|
@click.option(
|
|
"--skip-extract", is_flag=True, default=False, help="Skip the LLM extraction step."
|
|
)
|
|
def process(
|
|
limit: Optional[int],
|
|
skip_pull: bool,
|
|
skip_transcribe: bool,
|
|
skip_extract: bool,
|
|
) -> None:
|
|
"""Run the dev-side pipeline: article pulls + extract/embed.
|
|
|
|
Video pull + transcribe have moved to the tower-side
|
|
`transcribe-worker` daemon (faster-whisper on the 3080); this command
|
|
is article-only on the pull side, but still picks up TRANSCRIBED rows
|
|
of any source_type for the extract/embed step.
|
|
"""
|
|
from second_brain.adapters.article import ArticleAdapter
|
|
from second_brain.context.assembler import ContextAssembler
|
|
from second_brain.extractor.engine import ExtractionEngine
|
|
from second_brain.models import Extraction
|
|
|
|
config = load_config()
|
|
config.ensure_dirs()
|
|
db = get_database()
|
|
|
|
art_adapter = ArticleAdapter(config)
|
|
assembler = ContextAssembler(config)
|
|
|
|
engine: Optional[ExtractionEngine] = None
|
|
if not skip_extract:
|
|
ex_cfg = config.extractor
|
|
try:
|
|
engine = ExtractionEngine(
|
|
backend=ex_cfg["backend"],
|
|
model=ex_cfg["model"],
|
|
api_key=config.anthropic_api_key,
|
|
cli_binary=ex_cfg["cli_binary"],
|
|
timeout_sec=ex_cfg["timeout_sec"],
|
|
)
|
|
except ValueError as exc:
|
|
click.echo(f"[process] Warning: {exc}")
|
|
click.echo("[process] Extraction step will be skipped.")
|
|
skip_extract = True
|
|
|
|
# Dev side: pull only ARTICLE sources (videos belong to the tower
|
|
# worker) — but pick up TRANSCRIBED of any type for extract/embed.
|
|
with db.session() as sess:
|
|
pull_ids = []
|
|
if not skip_pull:
|
|
pull_ids = [
|
|
row[0]
|
|
for row in sess.query(Source.id)
|
|
.filter(
|
|
Source.status == SourceStatus.PENDING,
|
|
Source.source_type == SourceType.ARTICLE,
|
|
)
|
|
.all()
|
|
]
|
|
extract_ids = [
|
|
row[0]
|
|
for row in sess.query(Source.id)
|
|
.filter(Source.status == SourceStatus.TRANSCRIBED)
|
|
.all()
|
|
]
|
|
# Keep order stable (pulls first so freshly-transcribed articles
|
|
# roll into extract in the same invocation) and de-duplicate.
|
|
source_ids: list[int] = []
|
|
for sid in pull_ids + extract_ids:
|
|
if sid not in source_ids:
|
|
source_ids.append(sid)
|
|
if limit:
|
|
source_ids = source_ids[:limit]
|
|
|
|
if not source_ids:
|
|
click.echo("[process] No sources to process.")
|
|
return
|
|
|
|
click.echo(f"[process] Processing {len(source_ids)} source(s)…")
|
|
if skip_transcribe:
|
|
click.echo("[process] (--skip-transcribe is now a no-op — transcription "
|
|
"runs on the tower worker.)")
|
|
|
|
for source_id in source_ids:
|
|
with db.session() as sess:
|
|
source = sess.get(Source, source_id)
|
|
click.echo(f"\n [{source.id}] {source.url[:80]}")
|
|
|
|
# Pull step — articles only on the dev side.
|
|
if (
|
|
not skip_pull
|
|
and source.status == SourceStatus.PENDING
|
|
and source.source_type == SourceType.ARTICLE
|
|
):
|
|
click.echo(" → pull (article)")
|
|
ok = art_adapter.pull(source)
|
|
if not ok:
|
|
click.echo(f" ✗ pull failed: {source.error_message}")
|
|
continue
|
|
|
|
# Extract step
|
|
if not skip_extract and source.status == SourceStatus.TRANSCRIBED and engine:
|
|
click.echo(" → extract")
|
|
try:
|
|
prompt = assembler.assemble(source)
|
|
result = engine.extract(prompt, source)
|
|
|
|
existing_ext = sess.query(Extraction).filter_by(
|
|
source_id=source.id
|
|
).first()
|
|
if existing_ext:
|
|
for k, v in result.to_db_dict().items():
|
|
setattr(existing_ext, k, v)
|
|
existing_ext.raw_response = getattr(result, "_raw_response", None)
|
|
existing_ext.input_tokens = getattr(result, "_input_tokens", None)
|
|
existing_ext.output_tokens = getattr(result, "_output_tokens", None)
|
|
ext_row = existing_ext
|
|
else:
|
|
ext_row = Extraction(
|
|
source_id=source.id,
|
|
raw_response=getattr(result, "_raw_response", None),
|
|
input_tokens=getattr(result, "_input_tokens", None),
|
|
output_tokens=getattr(result, "_output_tokens", None),
|
|
**result.to_db_dict(),
|
|
)
|
|
sess.add(ext_row)
|
|
|
|
source.status = SourceStatus.ANALYZED
|
|
source.updated_at = utcnow()
|
|
# Flush so the new extraction gets its PK before we kick
|
|
# off embedding (we key public.embeddings rows by it).
|
|
sess.flush()
|
|
extraction_id = ext_row.id
|
|
extraction_summary = ext_row.summary
|
|
click.echo(" ✓ analyzed")
|
|
|
|
if config.embeddings.get("enabled", True):
|
|
from second_brain.embeddings import embed_extraction
|
|
|
|
n = embed_extraction(extraction_id, extraction_summary)
|
|
if n is not None:
|
|
click.echo(f" ✓ embedded ({n} chunk(s))")
|
|
except Exception as exc:
|
|
source.error_message = str(exc)
|
|
source.status = SourceStatus.FAILED
|
|
click.echo(f" ✗ extraction failed: {exc}")
|
|
|
|
click.echo("\n[process] Done.")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# serve
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@cli.command()
|
|
@click.option("--host", default="127.0.0.1", show_default=True)
|
|
@click.option("--port", default=8000, show_default=True, type=int)
|
|
@click.option("--reload", is_flag=True, default=False)
|
|
def serve(host: str, port: int, reload: bool) -> None:
|
|
"""Launch the web UI."""
|
|
import uvicorn
|
|
|
|
click.echo(f"[serve] Starting on http://{host}:{port}")
|
|
uvicorn.run(
|
|
"second_brain.web.app:app",
|
|
host=host,
|
|
port=port,
|
|
reload=reload,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# compile
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@cli.command("compile")
|
|
def compile_wiki() -> None:
|
|
"""Run the wiki compiler on accepted sources."""
|
|
from second_brain.compiler.wiki import WikiCompiler
|
|
|
|
config = load_config()
|
|
compiler = WikiCompiler(config)
|
|
stats = compiler.run()
|
|
click.echo(
|
|
f"[compile] processed={stats['processed']} "
|
|
f"created={stats['created']} updated={stats['updated']} "
|
|
f"errors={stats['errors']}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# schedule
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@cli.command()
|
|
@click.option("--dry-run", is_flag=True, default=False)
|
|
def schedule(dry_run: bool) -> None:
|
|
"""Start the overnight scheduler."""
|
|
from second_brain.scheduler.runner import Scheduler
|
|
|
|
config = load_config()
|
|
scheduler = Scheduler(config)
|
|
scheduler.run(dry_run=dry_run)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# transcribe-worker (tower-side daemon)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@cli.command("transcribe-worker")
|
|
@click.option("--once", is_flag=True, default=False,
|
|
help="Process at most one source then exit (useful for ad-hoc runs / tests).")
|
|
@click.option("--poll-interval", default=None, type=int,
|
|
help="Seconds between empty-queue polls (default 15).")
|
|
@click.option("--worker-id", default=None,
|
|
help="Identifier stamped into sources.claimed_by (default tower:<hostname>).")
|
|
def transcribe_worker(
|
|
once: bool,
|
|
poll_interval: Optional[int],
|
|
worker_id: Optional[str],
|
|
) -> None:
|
|
"""Long-running tower-side worker: claim → yt-dlp pull → faster-whisper.
|
|
|
|
Reads pipeline_settings each iteration so the dashboard's enable
|
|
toggle / active-hours window / max-items-per-run / max-video-length
|
|
all take effect within one poll cycle.
|
|
"""
|
|
import logging
|
|
|
|
from second_brain.scheduler.transcribe_worker import (
|
|
DEFAULT_POLL_INTERVAL_SECONDS,
|
|
TranscribeWorker,
|
|
)
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
|
|
config = load_config()
|
|
config.ensure_dirs()
|
|
worker = TranscribeWorker(
|
|
config,
|
|
worker_id=worker_id,
|
|
poll_interval=poll_interval or DEFAULT_POLL_INTERVAL_SECONDS,
|
|
)
|
|
processed = worker.run(once=once)
|
|
click.echo(f"[transcribe-worker] exiting; processed={processed}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
cli()
|