developer commited on
Commit
f4d9ca1
·
0 Parent(s):

Scaffold YouTube Channel Knowledge Base (RAG)

Browse files
Files changed (16) hide show
  1. .env.example +24 -0
  2. .gitignore +18 -0
  3. README.md +78 -0
  4. app.py +73 -0
  5. eval/run_eval.py +92 -0
  6. eval/testset.example.json +10 -0
  7. requirements.txt +13 -0
  8. scripts/build_index.py +33 -0
  9. src/__init__.py +0 -0
  10. src/chunk.py +90 -0
  11. src/config.py +42 -0
  12. src/embed.py +23 -0
  13. src/ingest.py +130 -0
  14. src/llm.py +75 -0
  15. src/rag.py +54 -0
  16. src/store.py +85 -0
.env.example ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---- LLM provider ----
2
+ # Options: "ollama" (local, free) or "anthropic" (Claude, for deployed demo)
3
+ LLM_PROVIDER=ollama
4
+
5
+ # Local Ollama settings
6
+ OLLAMA_MODEL=qwen2.5:7b-instruct
7
+ OLLAMA_HOST=http://localhost:11434
8
+
9
+ # Anthropic (Claude) settings — used when LLM_PROVIDER=anthropic
10
+ ANTHROPIC_API_KEY=
11
+ ANTHROPIC_MODEL=claude-haiku-4-5-20251001
12
+
13
+ # ---- Embeddings (local, free) ----
14
+ EMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
15
+
16
+ # ---- Retrieval ----
17
+ TOP_K=6
18
+ CHUNK_TARGET_WORDS=180
19
+ CHUNK_OVERLAP_WORDS=30
20
+
21
+ # ---- Storage paths ----
22
+ CHROMA_DIR=data/chroma
23
+ TRANSCRIPT_DIR=data/transcripts
24
+ COLLECTION_NAME=youtube_kb
.gitignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ venv/
6
+ *.egg-info/
7
+
8
+ # Secrets
9
+ .env
10
+
11
+ # Data artifacts (rebuilt by ingestion)
12
+ data/transcripts/
13
+ data/chroma/
14
+ data/*.json
15
+
16
+ # Models / caches
17
+ .cache/
18
+ *.log
README.md ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: YouTube Channel Knowledge Base
3
+ emoji: 🎥
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: streamlit
7
+ sdk_version: 1.40.0
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ # 🎥 YouTube Channel Knowledge Base (RAG)
13
+
14
+ Turn an entire YouTube channel into a searchable, conversational knowledge base.
15
+ Ask a question in plain language and get an answer **grounded in the video
16
+ transcripts**, with citations that deep-link to the exact **video and timestamp**.
17
+
18
+ ## How it works
19
+
20
+ ```
21
+ channel ─▶ yt-dlp (video list)
22
+ └▶ youtube-transcript-api (timed captions)
23
+
24
+
25
+ chunk (timestamp-anchored) ─▶ sentence-transformers embeddings ─▶ ChromaDB
26
+
27
+ question ─▶ embed ─▶ vector search ─▶ top-k excerpts ─▶ LLM (grounded) ─▶ answer + [n] citations
28
+ ```
29
+
30
+ - **Embeddings:** local `sentence-transformers` — free, no API calls.
31
+ - **LLM:** pluggable — `ollama` (local, free) for dev, `anthropic` (Claude) for the deployed demo.
32
+ - **Citations:** every answer links back to `youtube.com/watch?v=…&t=…s`.
33
+
34
+ ## Quickstart (local)
35
+
36
+ ```powershell
37
+ python -m venv .venv
38
+ .\.venv\Scripts\Activate.ps1
39
+ pip install -r requirements.txt
40
+ copy .env.example .env # then edit if needed
41
+
42
+ # 1) Build the index for a channel (transcripts are cached to disk)
43
+ python -m scripts.build_index --channel "@SomeChannel" --limit 50
44
+
45
+ # 2) Run the app
46
+ streamlit run app.py
47
+ ```
48
+
49
+ Local answers use Ollama by default (`LLM_PROVIDER=ollama`). Make sure a model is
50
+ pulled, e.g. `ollama pull qwen2.5:7b-instruct`.
51
+
52
+ ## Switching to Claude
53
+
54
+ Set in `.env` (or as environment variables):
55
+
56
+ ```
57
+ LLM_PROVIDER=anthropic
58
+ ANTHROPIC_API_KEY=sk-ant-...
59
+ ANTHROPIC_MODEL=claude-haiku-4-5-20251001
60
+ ```
61
+
62
+ ## Evaluation
63
+
64
+ ```powershell
65
+ python -m eval.run_eval --testset eval/testset.json
66
+ ```
67
+
68
+ Runs deterministic checks (citation validity, out-of-scope refusal) plus an
69
+ LLM-as-judge score for faithfulness and citation quality.
70
+
71
+ ## Deploy (Hugging Face Spaces)
72
+
73
+ 1. Build the index locally, then commit `data/chroma/` (remove it from
74
+ `.gitignore` for the deploy commit) so the Space ships with a prebuilt index —
75
+ Spaces cannot run ingestion or Ollama.
76
+ 2. Create a **Streamlit** Space and push this repo.
77
+ 3. In the Space **Settings → Secrets**, set `LLM_PROVIDER=anthropic` and
78
+ `ANTHROPIC_API_KEY`.
app.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Streamlit UI for the YouTube Channel Knowledge Base.
2
+
3
+ Ask a question -> grounded answer with clickable video + timestamp citations.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import streamlit as st
8
+
9
+ from src.chunk import _fmt_timestamp
10
+ from src.config import CONFIG
11
+ from src.llm import get_provider
12
+ from src.rag import answer_question
13
+ from src.store import count
14
+
15
+ st.set_page_config(page_title="YouTube Channel Knowledge Base", page_icon="🎥", layout="centered")
16
+
17
+ st.title("🎥 YouTube Channel Knowledge Base")
18
+ st.caption("Ask anything about the channel's videos — answers are grounded in the "
19
+ "transcripts and cite the exact video and timestamp.")
20
+
21
+ with st.sidebar:
22
+ st.header("Status")
23
+ n = count()
24
+ st.metric("Indexed transcript chunks", n)
25
+ if n == 0:
26
+ st.warning("No index found. Run:\n\n`python -m scripts.build_index --channel \"@handle\"`")
27
+
28
+ st.divider()
29
+ st.subheader("Settings")
30
+ provider_choice = st.selectbox(
31
+ "Answer model",
32
+ options=["(use .env default)", "ollama", "anthropic"],
33
+ index=0,
34
+ )
35
+ top_k = st.slider("Sources retrieved", 3, 12, CONFIG.top_k)
36
+ st.caption(f"Embeddings: `{CONFIG.embed_model}` (local)")
37
+
38
+ if "history" not in st.session_state:
39
+ st.session_state.history = []
40
+
41
+ for q, ans in st.session_state.history:
42
+ with st.chat_message("user"):
43
+ st.markdown(q)
44
+ with st.chat_message("assistant"):
45
+ st.markdown(ans.text)
46
+
47
+ question = st.chat_input("Ask a question about the channel...")
48
+ if question:
49
+ with st.chat_message("user"):
50
+ st.markdown(question)
51
+
52
+ with st.chat_message("assistant"):
53
+ provider = None if provider_choice.startswith("(") else get_provider(provider_choice)
54
+ with st.spinner("Searching the channel and composing an answer..."):
55
+ try:
56
+ ans = answer_question(question, provider=provider, top_k=top_k)
57
+ except Exception as e: # surface provider/setup errors clearly
58
+ st.error(f"Error: {e}")
59
+ st.stop()
60
+
61
+ st.markdown(ans.text)
62
+
63
+ if ans.sources:
64
+ st.markdown("**Sources**")
65
+ for i, s in enumerate(ans.sources, 1):
66
+ st.markdown(
67
+ f"{i}. [{s.title} @ {_fmt_timestamp(s.start)}]({s.url}) "
68
+ f"<span style='color:gray'>· similarity {s.score:.2f}</span>",
69
+ unsafe_allow_html=True,
70
+ )
71
+ st.caption(f"Answered by: {ans.provider}")
72
+
73
+ st.session_state.history.append((question, ans))
eval/run_eval.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lightweight evaluation harness.
2
+
3
+ Two layers, matching the JD's "deterministic + LLM-as-judge + human review":
4
+
5
+ 1. Deterministic checks (no model): every cited [n] in the answer must map to a
6
+ real retrieved source, and out-of-scope questions must trigger a refusal.
7
+ 2. LLM-as-judge: a model scores faithfulness (is the answer supported by the
8
+ retrieved excerpts?) and citation usefulness on a 1-5 scale.
9
+
10
+ Usage:
11
+ python -m eval.run_eval --testset eval/testset.json
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import re
18
+
19
+ from src.llm import get_provider
20
+ from src.rag import answer_question
21
+
22
+ CITATION_RE = re.compile(r"\[(\d+)\]")
23
+ REFUSAL_MARKERS = ("don't know", "do not know", "couldn't find", "could not find", "no information")
24
+
25
+ JUDGE_SYSTEM = (
26
+ "You are a strict evaluator. Given a question, the retrieved transcript "
27
+ "excerpts, and an assistant answer, rate two things from 1-5:\n"
28
+ " faithfulness: is every claim supported by the excerpts (5) or are there "
29
+ "unsupported/hallucinated claims (1)?\n"
30
+ " citation_quality: are the [n] citations present and pointing at relevant "
31
+ "excerpts (5) or missing/wrong (1)?\n"
32
+ "Respond ONLY as JSON: {\"faithfulness\": int, \"citation_quality\": int, \"reason\": str}."
33
+ )
34
+
35
+
36
+ def deterministic_checks(answer_text: str, n_sources: int) -> dict:
37
+ cited = {int(m) for m in CITATION_RE.findall(answer_text)}
38
+ invalid = {c for c in cited if c < 1 or c > n_sources}
39
+ refused = any(m in answer_text.lower() for m in REFUSAL_MARKERS)
40
+ return {
41
+ "has_citations": bool(cited),
42
+ "all_citations_valid": len(invalid) == 0,
43
+ "invalid_citations": sorted(invalid),
44
+ "refused": refused,
45
+ }
46
+
47
+
48
+ def judge(question: str, ans, judge_provider) -> dict:
49
+ excerpts = "\n\n".join(f"[{i}] {s.text}" for i, s in enumerate(ans.sources, 1))
50
+ user = (
51
+ f"Question: {question}\n\nExcerpts:\n{excerpts}\n\nAnswer:\n{ans.text}\n\n"
52
+ "Return the JSON now."
53
+ )
54
+ raw = judge_provider.complete(JUDGE_SYSTEM, user)
55
+ try:
56
+ start, end = raw.find("{"), raw.rfind("}")
57
+ return json.loads(raw[start : end + 1])
58
+ except Exception:
59
+ return {"faithfulness": None, "citation_quality": None, "reason": f"unparseable: {raw[:120]}"}
60
+
61
+
62
+ def main() -> None:
63
+ parser = argparse.ArgumentParser()
64
+ parser.add_argument("--testset", default="eval/testset.json")
65
+ args = parser.parse_args()
66
+
67
+ with open(args.testset, "r", encoding="utf-8") as f:
68
+ cases = json.load(f)
69
+
70
+ answer_provider = get_provider()
71
+ judge_provider = get_provider() # same backend; swap to a stronger model if desired
72
+
73
+ rows = []
74
+ for case in cases:
75
+ q = case["question"]
76
+ ans = answer_question(q, provider=answer_provider)
77
+ det = deterministic_checks(ans.text, len(ans.sources))
78
+ verdict = judge(q, ans, judge_provider) if ans.sources else {"faithfulness": None, "citation_quality": None, "reason": "no sources"}
79
+ rows.append({"question": q, "deterministic": det, "judge": verdict})
80
+ print(f"\nQ: {q}\n deterministic: {det}\n judge: {verdict}")
81
+
82
+ faith = [r["judge"]["faithfulness"] for r in rows if isinstance(r["judge"].get("faithfulness"), int)]
83
+ if faith:
84
+ print(f"\nMean faithfulness: {sum(faith)/len(faith):.2f}/5 over {len(faith)} cases")
85
+
86
+ with open("eval/results.json", "w", encoding="utf-8") as f:
87
+ json.dump(rows, f, indent=2, ensure_ascii=False)
88
+ print("\nWrote eval/results.json")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
eval/testset.example.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "question": "What is the main topic the channel covers most often?",
4
+ "expected_points": ["mentions a recurring theme present in multiple videos"]
5
+ },
6
+ {
7
+ "question": "Ask something the channel has clearly never discussed (out-of-scope)",
8
+ "expected_points": ["assistant says it doesn't know based on this channel's videos"]
9
+ }
10
+ ]
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core RAG stack
2
+ streamlit>=1.40
3
+ yt-dlp>=2024.12.13
4
+ youtube-transcript-api>=0.6.2
5
+ sentence-transformers>=3.0
6
+ chromadb>=0.5.5
7
+ anthropic>=0.40
8
+ python-dotenv>=1.0
9
+ tqdm>=4.66
10
+ requests>=2.31
11
+
12
+ # Optional: Whisper fallback for videos without captions (heavier install)
13
+ # faster-whisper>=1.0
scripts/build_index.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI: ingest a YouTube channel and build the vector index.
2
+
3
+ Usage:
4
+ python -m scripts.build_index --channel "@SomeChannel" --limit 50
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+
10
+ from src.chunk import chunk_all
11
+ from src.ingest import ingest_channel
12
+ from src.store import count, index_chunks
13
+
14
+
15
+ def main() -> None:
16
+ parser = argparse.ArgumentParser(description="Build the YouTube knowledge base index.")
17
+ parser.add_argument("--channel", required=True, help="Channel handle (@name), URL, or name")
18
+ parser.add_argument("--limit", type=int, default=None, help="Max number of videos to ingest")
19
+ args = parser.parse_args()
20
+
21
+ records = ingest_channel(args.channel, limit=args.limit)
22
+ if not records:
23
+ print("No transcripts ingested — nothing to index.")
24
+ return
25
+
26
+ chunks = chunk_all(records)
27
+ print(f"Built {len(chunks)} chunks from {len(records)} videos. Embedding + indexing...")
28
+ index_chunks(chunks)
29
+ print(f"Done. Collection now holds {count()} chunks.")
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
src/__init__.py ADDED
File without changes
src/chunk.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn transcript segments into overlapping, timestamp-anchored chunks.
2
+
3
+ Each chunk carries the start time of its first segment so we can build a
4
+ deep-link citation (youtube.com/watch?v=ID&t=SECONDS) back to the exact moment.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+
10
+ from .config import CONFIG
11
+
12
+
13
+ @dataclass
14
+ class Chunk:
15
+ id: str
16
+ text: str
17
+ video_id: str
18
+ title: str
19
+ start: float # seconds into the video
20
+ url: str # deep link with timestamp
21
+
22
+
23
+ def _fmt_timestamp(seconds: float) -> str:
24
+ s = int(seconds)
25
+ h, s = divmod(s, 3600)
26
+ m, s = divmod(s, 60)
27
+ return f"{h:d}:{m:02d}:{s:02d}" if h else f"{m:d}:{s:02d}"
28
+
29
+
30
+ def chunk_record(record: dict,
31
+ target_words: int | None = None,
32
+ overlap_words: int | None = None) -> list[Chunk]:
33
+ """Chunk one ingested {meta, segments} record into Chunk objects."""
34
+ target_words = target_words or CONFIG.chunk_target_words
35
+ overlap_words = overlap_words or CONFIG.chunk_overlap_words
36
+
37
+ meta = record["meta"]
38
+ segments = record["segments"]
39
+ video_id = meta["video_id"]
40
+ title = meta["title"]
41
+
42
+ chunks: list[Chunk] = []
43
+ buf_words: list[str] = []
44
+ buf_start: float | None = None
45
+ idx = 0
46
+
47
+ def flush(start: float):
48
+ nonlocal buf_words, idx
49
+ if not buf_words:
50
+ return
51
+ text = " ".join(buf_words).strip()
52
+ ts = int(start)
53
+ chunk = Chunk(
54
+ id=f"{video_id}-{idx}",
55
+ text=text,
56
+ video_id=video_id,
57
+ title=title,
58
+ start=start,
59
+ url=f"https://www.youtube.com/watch?v={video_id}&t={ts}s",
60
+ )
61
+ chunks.append(chunk)
62
+ idx += 1
63
+
64
+ for seg in segments:
65
+ if buf_start is None:
66
+ buf_start = seg["start"]
67
+ buf_words.extend(seg["text"].split())
68
+
69
+ if len(buf_words) >= target_words:
70
+ flush(buf_start)
71
+ # keep an overlap tail for context continuity
72
+ tail = buf_words[-overlap_words:] if overlap_words else []
73
+ buf_words = list(tail)
74
+ buf_start = seg["start"]
75
+
76
+ if buf_words and buf_start is not None:
77
+ flush(buf_start)
78
+
79
+ return chunks
80
+
81
+
82
+ def chunk_all(records: list[dict]) -> list[Chunk]:
83
+ out: list[Chunk] = []
84
+ for rec in records:
85
+ out.extend(chunk_record(rec))
86
+ return out
87
+
88
+
89
+ def citation_label(chunk: Chunk) -> str:
90
+ return f"{chunk.title} @ {_fmt_timestamp(chunk.start)}"
src/config.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Central configuration, loaded from environment / .env."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass
6
+
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+
12
+ def _int(name: str, default: int) -> int:
13
+ try:
14
+ return int(os.getenv(name, str(default)))
15
+ except ValueError:
16
+ return default
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Config:
21
+ # LLM
22
+ llm_provider: str = os.getenv("LLM_PROVIDER", "ollama").lower()
23
+ ollama_model: str = os.getenv("OLLAMA_MODEL", "qwen2.5:7b-instruct")
24
+ ollama_host: str = os.getenv("OLLAMA_HOST", "http://localhost:11434")
25
+ anthropic_api_key: str = os.getenv("ANTHROPIC_API_KEY", "")
26
+ anthropic_model: str = os.getenv("ANTHROPIC_MODEL", "claude-haiku-4-5-20251001")
27
+
28
+ # Embeddings
29
+ embed_model: str = os.getenv("EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
30
+
31
+ # Retrieval / chunking
32
+ top_k: int = _int("TOP_K", 6)
33
+ chunk_target_words: int = _int("CHUNK_TARGET_WORDS", 180)
34
+ chunk_overlap_words: int = _int("CHUNK_OVERLAP_WORDS", 30)
35
+
36
+ # Storage
37
+ chroma_dir: str = os.getenv("CHROMA_DIR", "data/chroma")
38
+ transcript_dir: str = os.getenv("TRANSCRIPT_DIR", "data/transcripts")
39
+ collection_name: str = os.getenv("COLLECTION_NAME", "youtube_kb")
40
+
41
+
42
+ CONFIG = Config()
src/embed.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local sentence-transformers embeddings (free, no API calls)."""
2
+ from __future__ import annotations
3
+
4
+ from functools import lru_cache
5
+
6
+ from .config import CONFIG
7
+
8
+
9
+ @lru_cache(maxsize=1)
10
+ def _model():
11
+ from sentence_transformers import SentenceTransformer
12
+
13
+ return SentenceTransformer(CONFIG.embed_model)
14
+
15
+
16
+ def embed_texts(texts: list[str]) -> list[list[float]]:
17
+ model = _model()
18
+ vecs = model.encode(texts, normalize_embeddings=True, show_progress_bar=len(texts) > 64)
19
+ return [v.tolist() for v in vecs]
20
+
21
+
22
+ def embed_query(text: str) -> list[float]:
23
+ return embed_texts([text])[0]
src/ingest.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fetch a channel's video list and transcripts.
2
+
3
+ - Video discovery: yt-dlp (flat playlist) -> ids, titles, upload dates.
4
+ - Transcripts: youtube-transcript-api (timed caption segments).
5
+ - Everything is cached to disk so re-runs are cheap and offline-friendly.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from dataclasses import asdict, dataclass
12
+ from typing import Optional
13
+
14
+ from tqdm import tqdm
15
+
16
+ from .config import CONFIG
17
+
18
+
19
+ @dataclass
20
+ class VideoMeta:
21
+ video_id: str
22
+ title: str
23
+ url: str
24
+ upload_date: Optional[str] = None
25
+
26
+
27
+ @dataclass
28
+ class Segment:
29
+ text: str
30
+ start: float # seconds
31
+
32
+
33
+ def _channel_videos_url(channel: str) -> str:
34
+ """Normalise a channel handle/URL into its /videos tab URL for yt-dlp."""
35
+ channel = channel.strip()
36
+ if channel.startswith("http"):
37
+ return channel if channel.rstrip("/").endswith("/videos") else channel.rstrip("/") + "/videos"
38
+ if channel.startswith("@"):
39
+ return f"https://www.youtube.com/{channel}/videos"
40
+ return f"https://www.youtube.com/@{channel}/videos"
41
+
42
+
43
+ def list_channel_videos(channel: str, limit: Optional[int] = None) -> list[VideoMeta]:
44
+ """Return video metadata for a channel using yt-dlp's flat extractor."""
45
+ import yt_dlp
46
+
47
+ url = _channel_videos_url(channel)
48
+ opts = {"extract_flat": True, "quiet": True, "skip_download": True}
49
+ if limit:
50
+ opts["playlistend"] = limit
51
+
52
+ with yt_dlp.YoutubeDL(opts) as ydl:
53
+ info = ydl.extract_info(url, download=False)
54
+
55
+ entries = info.get("entries") or []
56
+ videos: list[VideoMeta] = []
57
+ for e in entries:
58
+ if not e or not e.get("id"):
59
+ continue
60
+ vid = e["id"]
61
+ videos.append(
62
+ VideoMeta(
63
+ video_id=vid,
64
+ title=e.get("title") or vid,
65
+ url=f"https://www.youtube.com/watch?v={vid}",
66
+ upload_date=e.get("upload_date"),
67
+ )
68
+ )
69
+ return videos
70
+
71
+
72
+ def fetch_transcript(video_id: str, languages: Optional[list[str]] = None) -> Optional[list[Segment]]:
73
+ """Fetch timed caption segments for a video, or None if unavailable."""
74
+ from youtube_transcript_api import (
75
+ NoTranscriptFound,
76
+ TranscriptsDisabled,
77
+ YouTubeTranscriptApi,
78
+ )
79
+
80
+ languages = languages or ["en", "en-US", "en-GB"]
81
+ try:
82
+ raw = YouTubeTranscriptApi.get_transcript(video_id, languages=languages)
83
+ except (TranscriptsDisabled, NoTranscriptFound):
84
+ # Try any available language as a fallback.
85
+ try:
86
+ listing = YouTubeTranscriptApi.list_transcripts(video_id)
87
+ transcript = next(iter(listing))
88
+ raw = transcript.fetch()
89
+ except Exception:
90
+ return None
91
+ except Exception:
92
+ return None
93
+
94
+ return [Segment(text=s["text"].strip(), start=float(s["start"])) for s in raw if s["text"].strip()]
95
+
96
+
97
+ def _cache_path(video_id: str) -> str:
98
+ return os.path.join(CONFIG.transcript_dir, f"{video_id}.json")
99
+
100
+
101
+ def ingest_channel(channel: str, limit: Optional[int] = None) -> list[dict]:
102
+ """Discover videos + transcripts for a channel, caching each to disk.
103
+
104
+ Returns a list of {meta, segments} dicts for videos that had a transcript.
105
+ """
106
+ os.makedirs(CONFIG.transcript_dir, exist_ok=True)
107
+ videos = list_channel_videos(channel, limit=limit)
108
+ print(f"Found {len(videos)} videos for channel '{channel}'.")
109
+
110
+ ingested: list[dict] = []
111
+ missing = 0
112
+ for meta in tqdm(videos, desc="Transcripts"):
113
+ path = _cache_path(meta.video_id)
114
+ if os.path.exists(path):
115
+ with open(path, "r", encoding="utf-8") as f:
116
+ ingested.append(json.load(f))
117
+ continue
118
+
119
+ segments = fetch_transcript(meta.video_id)
120
+ if not segments:
121
+ missing += 1
122
+ continue
123
+
124
+ record = {"meta": asdict(meta), "segments": [asdict(s) for s in segments]}
125
+ with open(path, "w", encoding="utf-8") as f:
126
+ json.dump(record, f, ensure_ascii=False)
127
+ ingested.append(record)
128
+
129
+ print(f"Ingested {len(ingested)} transcripts ({missing} videos had no captions).")
130
+ return ingested
src/llm.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pluggable LLM provider: Ollama (local) or Anthropic (Claude).
2
+
3
+ Selected via LLM_PROVIDER. Both expose the same .complete(system, user) API so
4
+ the rest of the app never needs to know which backend is in use.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Protocol
9
+
10
+ import requests
11
+
12
+ from .config import CONFIG
13
+
14
+
15
+ class LLMProvider(Protocol):
16
+ name: str
17
+
18
+ def complete(self, system: str, user: str) -> str: ...
19
+
20
+
21
+ class OllamaProvider:
22
+ name = "ollama"
23
+
24
+ def __init__(self, model: str | None = None, host: str | None = None):
25
+ self.model = model or CONFIG.ollama_model
26
+ self.host = (host or CONFIG.ollama_host).rstrip("/")
27
+
28
+ def complete(self, system: str, user: str) -> str:
29
+ resp = requests.post(
30
+ f"{self.host}/api/chat",
31
+ json={
32
+ "model": self.model,
33
+ "messages": [
34
+ {"role": "system", "content": system},
35
+ {"role": "user", "content": user},
36
+ ],
37
+ "stream": False,
38
+ "options": {"temperature": 0.2},
39
+ },
40
+ timeout=180,
41
+ )
42
+ resp.raise_for_status()
43
+ return resp.json()["message"]["content"].strip()
44
+
45
+
46
+ class AnthropicProvider:
47
+ name = "anthropic"
48
+
49
+ def __init__(self, model: str | None = None, api_key: str | None = None):
50
+ from anthropic import Anthropic
51
+
52
+ key = api_key or CONFIG.anthropic_api_key
53
+ if not key:
54
+ raise RuntimeError("ANTHROPIC_API_KEY is not set but LLM_PROVIDER=anthropic.")
55
+ self.model = model or CONFIG.anthropic_model
56
+ self.client = Anthropic(api_key=key)
57
+
58
+ def complete(self, system: str, user: str) -> str:
59
+ msg = self.client.messages.create(
60
+ model=self.model,
61
+ max_tokens=1024,
62
+ temperature=0.2,
63
+ system=system,
64
+ messages=[{"role": "user", "content": user}],
65
+ )
66
+ return "".join(block.text for block in msg.content if block.type == "text").strip()
67
+
68
+
69
+ def get_provider(name: str | None = None) -> LLMProvider:
70
+ name = (name or CONFIG.llm_provider).lower()
71
+ if name == "anthropic":
72
+ return AnthropicProvider()
73
+ if name == "ollama":
74
+ return OllamaProvider()
75
+ raise ValueError(f"Unknown LLM_PROVIDER: {name!r}")
src/rag.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retrieval-Augmented Generation: retrieve -> ground -> answer with citations."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from .config import CONFIG
7
+ from .llm import LLMProvider, get_provider
8
+ from .store import Retrieved, search
9
+
10
+ SYSTEM_PROMPT = (
11
+ "You are a knowledge-base assistant for a YouTube channel. "
12
+ "Answer ONLY using the provided transcript excerpts. "
13
+ "Every factual sentence must cite its source with a bracket like [1], [2] "
14
+ "matching the numbered excerpts. "
15
+ "If the excerpts do not contain the answer, say you don't know based on this "
16
+ "channel's videos — do not use outside knowledge. Be concise and specific."
17
+ )
18
+
19
+
20
+ @dataclass
21
+ class Answer:
22
+ text: str
23
+ sources: list[Retrieved]
24
+ provider: str
25
+
26
+
27
+ def _build_context(hits: list[Retrieved]) -> str:
28
+ blocks = []
29
+ for i, h in enumerate(hits, 1):
30
+ blocks.append(f"[{i}] (from \"{h.title}\")\n{h.text}")
31
+ return "\n\n".join(blocks)
32
+
33
+
34
+ def answer_question(question: str,
35
+ provider: LLMProvider | None = None,
36
+ top_k: int | None = None) -> Answer:
37
+ provider = provider or get_provider()
38
+ hits = search(question, top_k=top_k or CONFIG.top_k)
39
+
40
+ if not hits:
41
+ return Answer(
42
+ text="I couldn't find anything in this channel's videos about that.",
43
+ sources=[],
44
+ provider=provider.name,
45
+ )
46
+
47
+ context = _build_context(hits)
48
+ user = (
49
+ f"Transcript excerpts:\n\n{context}\n\n"
50
+ f"Question: {question}\n\n"
51
+ "Answer using only the excerpts above, with [n] citations."
52
+ )
53
+ text = provider.complete(SYSTEM_PROMPT, user)
54
+ return Answer(text=text, sources=hits, provider=provider.name)
src/store.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ChromaDB-backed vector store (persistent on disk)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass
6
+
7
+ from .chunk import Chunk
8
+ from .config import CONFIG
9
+ from .embed import embed_query, embed_texts
10
+
11
+
12
+ @dataclass
13
+ class Retrieved:
14
+ text: str
15
+ title: str
16
+ url: str
17
+ start: float
18
+ score: float
19
+
20
+
21
+ def _client():
22
+ import chromadb
23
+
24
+ os.makedirs(CONFIG.chroma_dir, exist_ok=True)
25
+ return chromadb.PersistentClient(path=CONFIG.chroma_dir)
26
+
27
+
28
+ def _collection():
29
+ client = _client()
30
+ # We supply our own embeddings, so no embedding_function is needed.
31
+ return client.get_or_create_collection(
32
+ name=CONFIG.collection_name,
33
+ metadata={"hnsw:space": "cosine"},
34
+ )
35
+
36
+
37
+ def index_chunks(chunks: list[Chunk], batch_size: int = 256) -> int:
38
+ """Embed and upsert chunks into the collection. Returns count indexed."""
39
+ col = _collection()
40
+ for i in range(0, len(chunks), batch_size):
41
+ batch = chunks[i : i + batch_size]
42
+ col.upsert(
43
+ ids=[c.id for c in batch],
44
+ documents=[c.text for c in batch],
45
+ embeddings=embed_texts([c.text for c in batch]),
46
+ metadatas=[
47
+ {"video_id": c.video_id, "title": c.title, "url": c.url, "start": c.start}
48
+ for c in batch
49
+ ],
50
+ )
51
+ return len(chunks)
52
+
53
+
54
+ def count() -> int:
55
+ try:
56
+ return _collection().count()
57
+ except Exception:
58
+ return 0
59
+
60
+
61
+ def search(query: str, top_k: int | None = None) -> list[Retrieved]:
62
+ top_k = top_k or CONFIG.top_k
63
+ col = _collection()
64
+ if col.count() == 0:
65
+ return []
66
+ res = col.query(
67
+ query_embeddings=[embed_query(query)],
68
+ n_results=top_k,
69
+ include=["documents", "metadatas", "distances"],
70
+ )
71
+ docs = res["documents"][0]
72
+ metas = res["metadatas"][0]
73
+ dists = res["distances"][0]
74
+ out: list[Retrieved] = []
75
+ for doc, meta, dist in zip(docs, metas, dists):
76
+ out.append(
77
+ Retrieved(
78
+ text=doc,
79
+ title=meta.get("title", ""),
80
+ url=meta.get("url", ""),
81
+ start=float(meta.get("start", 0.0)),
82
+ score=1.0 - float(dist), # cosine distance -> similarity
83
+ )
84
+ )
85
+ return out