rag-system / core /brain.py
joshsears's picture
Polish: BGE-large embeddings, contextual retrieval, 142 tests passing, lint clean
21ca2ea
Raw
History Blame Contribute Delete
13.3 kB
"""
Personal Second Brain — a dedicated knowledge collection inside the RAG system.
Features:
- Add notes (text), files (PDF/MD/TXT/DOCX), or URLs with custom tags
- Time-aware queries: filter retrieval by how recently content was added
- Tag-filtered retrieval: only search content matching specific tags
- Source browser: list everything in your brain with metadata
- Daily digest: summarize recent additions with the LLM
- Folder watcher: auto-ingest files dropped into a watched directory
"""
from __future__ import annotations
import logging
import tempfile
import time
from datetime import datetime
from pathlib import Path
from config import settings
from core.ingestion import get_chroma_client, get_embedding_model, ingest_document
from models import (
IngestResult,
QueryMode,
QueryResponse,
RetrievalContext,
RetrievalResult,
SourceCitation,
)
logger = logging.getLogger(__name__)
BRAIN_COLLECTION = "second_brain"
# ── Internal helpers ──────────────────────────────────────────────────────────
def _get_brain_collection():
client = get_chroma_client()
return client.get_or_create_collection(
name=BRAIN_COLLECTION,
metadata={"embedding_model": settings.embedding_model, "hnsw:space": "cosine"},
)
def _stamp_metadata(source: str, brain_type: str, title: str, tags: list[str]) -> None:
"""Update all chunks from `source` with brain-specific metadata fields."""
col = _get_brain_collection()
try:
results = col.get(where={"source_file": source}, include=["metadatas"])
except Exception:
results = col.get(include=["metadatas"])
results["ids"] = [
rid
for rid, m in zip(results["ids"], results["metadatas"])
if m.get("source_file") == source
]
results["metadatas"] = [m for m in results["metadatas"] if m.get("source_file") == source]
if not results["ids"]:
return
tags_str = ",".join(t.lower().strip() for t in tags if t.strip())
stamp = {
"brain_type": brain_type,
"brain_title": title or source,
"brain_tags": tags_str,
"brain_ingested_at": int(time.time()),
}
updated = [{**m, **stamp} for m in results["metadatas"]]
col.update(ids=results["ids"], metadatas=updated)
def _build_context(
question: str, docs: list[str], metas: list[dict], distances: list[float]
) -> RetrievalContext:
"""Build a RetrievalContext from raw ChromaDB query results."""
results = []
for i, (doc, meta, dist) in enumerate(zip(docs, metas, distances)):
score = max(0.0, min(1.0, 1.0 - dist))
results.append(
RetrievalResult(
chunk_text=doc,
source=meta.get("source_file", meta.get("brain_title", "brain")),
similarity_score=score,
chunk_index=i,
page_number=meta.get("page_number") if meta.get("page_number", -1) != -1 else None,
section_title=meta.get("section_title") or None,
metadata=meta,
)
)
return RetrievalContext(query=question, results=results, query_mode=QueryMode.HYBRID)
# ── Public API ────────────────────────────────────────────────────────────────
def add_note(text: str, title: str = "", tags: list[str] | None = None) -> IngestResult:
"""Ingest a plain-text note into the Second Brain."""
tags = tags or []
safe_title = (title or "note")[:40].replace("/", "-").replace(" ", "_")
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", prefix=f"brain_{safe_title}_", delete=False, encoding="utf-8"
) as f:
if title:
f.write(f"# {title}\n\n")
f.write(text)
tmp_path = f.name
try:
result = ingest_document(tmp_path, collection_name=BRAIN_COLLECTION)
_stamp_metadata(tmp_path, brain_type="note", title=title or "Note", tags=tags)
finally:
Path(tmp_path).unlink(missing_ok=True)
return result
def add_source(path_or_url: str, tags: list[str] | None = None, title: str = "") -> IngestResult:
"""Ingest a file path or URL into the Second Brain."""
tags = tags or []
is_url = path_or_url.startswith(("http://", "https://"))
brain_type = "url" if is_url else "file"
derived_title = title or (path_or_url if is_url else Path(path_or_url).name)
result = ingest_document(path_or_url, collection_name=BRAIN_COLLECTION)
_stamp_metadata(path_or_url, brain_type=brain_type, title=derived_title, tags=tags)
return result
def query_brain(
question: str,
tags: list[str] | None = None,
days: int | None = None,
top_k: int = 8,
) -> QueryResponse:
"""
Query the Second Brain with optional tag and time filters.
Directly queries ChromaDB with a time where-clause, then applies
tag filtering in Python, then runs LLM generation on filtered chunks.
"""
import time as _time
from core.generation import SYSTEM_PROMPT, build_user_prompt, get_backend
tags = tags or []
col = _get_brain_collection()
if col.count() == 0:
return QueryResponse(
question=question,
answer="Your Second Brain is empty. Add some notes, files, or URLs first.",
tokens_used=0,
latency_ms=0.0,
collection=BRAIN_COLLECTION,
llm_backend=settings.llm_backend.value,
model_used="",
)
start = _time.perf_counter()
model = get_embedding_model()
q_emb = model.encode([question], normalize_embeddings=True)[0].tolist()
# Build where clause for time filter
where: dict | None = None
if days is not None:
cutoff = int(_time.time()) - days * 86400
where = {"brain_ingested_at": {"$gte": cutoff}}
n_results = min(top_k * 3, max(col.count(), 1))
try:
raw = col.query(
query_embeddings=[q_emb],
n_results=n_results,
where=where,
include=["documents", "metadatas", "distances"],
)
except Exception as e:
# where clause can fail if no docs have that metadata field
logger.warning("Brain query with where clause failed (%s), falling back to unfiltered", e)
raw = col.query(
query_embeddings=[q_emb],
n_results=n_results,
include=["documents", "metadatas", "distances"],
)
docs = raw["documents"][0] if raw["documents"] else []
metas = raw["metadatas"][0] if raw["metadatas"] else []
dists = raw["distances"][0] if raw["distances"] else []
# Python-side tag filter
if tags:
filtered = [
(d, m, s)
for d, m, s in zip(docs, metas, dists)
if any(t.lower() in m.get("brain_tags", "").lower() for t in tags)
]
if filtered:
docs, metas, dists = zip(*filtered) # type: ignore[assignment]
else:
docs, metas, dists = [], [], []
docs = list(docs)[:top_k]
metas = list(metas)[:top_k]
dists = list(dists)[:top_k]
if not docs:
return QueryResponse(
question=question,
answer="No matching content found in your Second Brain for those filters. Try broadening your tags or time range.",
tokens_used=0,
latency_ms=(_time.perf_counter() - start) * 1000,
collection=BRAIN_COLLECTION,
llm_backend=settings.llm_backend.value,
model_used="",
)
context = _build_context(question, docs, metas, dists)
backend = get_backend()
user_prompt = build_user_prompt(context)
answer, tokens, model_used = backend.complete(SYSTEM_PROMPT, user_prompt)
sources = [
SourceCitation(
source=r.source,
chunk_index=r.chunk_index,
page_number=r.page_number,
similarity_score=r.similarity_score,
excerpt=r.chunk_text[:200],
)
for r in context.results
]
return QueryResponse(
question=question,
answer=answer,
sources=sources,
tokens_used=tokens,
latency_ms=(_time.perf_counter() - start) * 1000,
collection=BRAIN_COLLECTION,
llm_backend=settings.llm_backend.value,
model_used=model_used,
retrieval_context=context,
)
def list_sources(
tags: list[str] | None = None,
days: int | None = None,
limit: int = 50,
) -> list[dict]:
"""Return deduplicated source entries from the brain, newest first."""
col = _get_brain_collection()
if col.count() == 0:
return []
results = col.get(include=["metadatas"])
seen: set[str] = set()
sources: list[dict] = []
cutoff = (int(time.time()) - days * 86400) if days else 0
for meta in results["metadatas"]:
src_key = meta.get("source_file", "")
if src_key in seen:
continue
seen.add(src_key)
ingested_at = meta.get("brain_ingested_at", 0)
if days and ingested_at < cutoff:
continue
if tags:
chunk_tags = meta.get("brain_tags", "")
if not any(t.lower() in chunk_tags.lower() for t in tags):
continue
sources.append(meta)
sources.sort(key=lambda m: m.get("brain_ingested_at", 0), reverse=True)
return sources[:limit]
def get_all_tags() -> dict[str, int]:
"""Return {tag: chunk_count} for all tags in the brain."""
col = _get_brain_collection()
if col.count() == 0:
return {}
results = col.get(include=["metadatas"])
counts: dict[str, int] = {}
for meta in results["metadatas"]:
for tag in meta.get("brain_tags", "").split(","):
tag = tag.strip()
if tag:
counts[tag] = counts.get(tag, 0) + 1
return counts
def daily_digest(days: int = 1) -> str:
"""Ask the LLM to summarize what was added to the brain in the last N days."""
from core.generation import get_backend
sources = list_sources(days=days, limit=30)
if not sources:
label = "today" if days == 1 else f"the last {days} days"
return f"Nothing was added to your Second Brain in {label}."
lines = []
seen: set[str] = set()
for m in sources:
title = m.get("brain_title", m.get("source_file", "Unknown"))
if title in seen:
continue
seen.add(title)
brain_type = m.get("brain_type", "item")
tags = m.get("brain_tags", "")
ts = m.get("brain_ingested_at", 0)
dt = datetime.fromtimestamp(ts).strftime("%b %d %H:%M") if ts else "unknown time"
lines.append(
f"- [{brain_type}] {title} (added {dt})" + (f" — tags: {tags}" if tags else "")
)
items_list = "\n".join(lines)
label = "today" if days == 1 else f"the last {days} days"
prompt = (
f"Here are the items added to my personal knowledge base in {label}:\n\n"
f"{items_list}\n\n"
"Write a brief, friendly digest summarizing what was captured and any interesting patterns "
"or themes across the new content. Keep it under 200 words."
)
backend = get_backend()
answer, _, _ = backend.complete(
"You are a helpful personal knowledge assistant.",
prompt,
)
return answer
def watch_folder(
directory: str | Path,
tags: list[str] | None = None,
poll_interval: float = 2.0,
) -> None:
"""
Watch a directory and auto-ingest any new files into the Second Brain.
Blocks until KeyboardInterrupt. Run in a background thread for non-blocking use.
"""
try:
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
except ImportError as exc:
raise ImportError(
"watchdog is required for folder watching. Install it with: pip install watchdog"
) from exc
directory = Path(directory)
tags = tags or []
supported = {".pdf", ".txt", ".md", ".docx", ".markdown"}
class _Handler(FileSystemEventHandler):
def on_created(self, event):
if event.is_directory:
return
path = Path(event.src_path)
if path.suffix.lower() not in supported:
return
logger.info("Brain watcher: new file detected: %s", path)
try:
result = add_source(str(path), tags=tags)
logger.info(
"Brain watcher: ingested %d chunks from %s", result.chunks_added, path.name
)
except Exception as e:
logger.error("Brain watcher: failed to ingest %s: %s", path, e)
observer = Observer()
observer.schedule(_Handler(), str(directory), recursive=False)
observer.start()
logger.info("Brain watcher started on '%s'. Press Ctrl+C to stop.", directory)
try:
while observer.is_alive():
observer.join(timeout=poll_interval)
except KeyboardInterrupt:
observer.stop()
observer.join()
logger.info("Brain watcher stopped.")