Spaces:
Sleeping
Sleeping
developer commited on
Commit ·
f4d9ca1
0
Parent(s):
Scaffold YouTube Channel Knowledge Base (RAG)
Browse files- .env.example +24 -0
- .gitignore +18 -0
- README.md +78 -0
- app.py +73 -0
- eval/run_eval.py +92 -0
- eval/testset.example.json +10 -0
- requirements.txt +13 -0
- scripts/build_index.py +33 -0
- src/__init__.py +0 -0
- src/chunk.py +90 -0
- src/config.py +42 -0
- src/embed.py +23 -0
- src/ingest.py +130 -0
- src/llm.py +75 -0
- src/rag.py +54 -0
- 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
|