second-brain/src/second_brain/main.py
Travis Herbranson a2d46a7c6e gauntlet: fix lint — drop unused imports + reorder models.py + add dev deps
Ruff autofix removed pre-existing F401 unused imports across adapters,
context, compiler, extractor, scheduler, web, and main. Also reordered
models.py so its SQLAlchemy imports sit at the top of the file
(E402 was triggered by the inline `utcnow` helper definition).

Added a [dependency-groups] dev block (pytest, pytest-cov, ruff) so the
zero-check test gauntlet — which runs `pytest --cov --cov-report=term-missing`
— can resolve its plugins without a manual `uv pip install pytest-cov`.
2026-05-24 22:52:08 -04:00

308 lines
10 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
from typing import Optional
from urllib.parse import urlparse
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."""
config = load_config()
config.ensure_dirs()
db = get_database()
source_type = SourceType.ARTICLE if _is_article(url) else SourceType.VIDEO
with db.session() as sess:
existing = sess.query(Source).filter_by(url=url).first()
if existing:
click.echo(
f"[add] Already queued (id={existing.id}, status={existing.status.value})"
)
return
source = Source(
url=url,
title=title,
domain=domain,
focus=focus,
source_type=source_type,
status=SourceStatus.PENDING,
ingested_at=utcnow(),
)
sess.add(source)
click.echo(f"[add] Queued {source_type.value}: {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 pipeline on pending sources."""
from second_brain.adapters.youtube import YouTubeAdapter
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()
yt_adapter = YouTubeAdapter(config)
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
with db.session() as sess:
query = sess.query(Source.id).filter(
Source.status.in_([
SourceStatus.PENDING,
SourceStatus.PULLED,
SourceStatus.TRANSCRIBED,
])
)
if limit:
query = query.limit(limit)
source_ids = [row[0] for row in query.all()]
if not source_ids:
click.echo("[process] No sources to process.")
return
click.echo(f"[process] Processing {len(source_ids)} source(s)…")
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
if not skip_pull and source.status == SourceStatus.PENDING:
click.echo(" → pull")
if source.source_type == SourceType.VIDEO:
ok = yt_adapter.pull(source)
else:
ok = art_adapter.pull(source)
if not ok:
click.echo(f" ✗ pull failed: {source.error_message}")
continue
# Transcribe step (videos only — articles come out transcribed)
if (
not skip_transcribe
and source.status == SourceStatus.PULLED
and source.source_type == SourceType.VIDEO
):
click.echo(" → transcribe")
ok = yt_adapter.transcribe(source)
if not ok:
click.echo(f" ✗ transcribe 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)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _is_article(url: str) -> bool:
parsed = urlparse(url)
video_hosts = {
"youtube.com", "www.youtube.com", "youtu.be",
"vimeo.com", "www.vimeo.com",
}
return parsed.netloc not in video_hosts
if __name__ == "__main__":
cli()