diff --git a/CHANGELOG.md b/CHANGELOG.md index b443851..0b9e96e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to Notebook LM will be documented in this file. +## [0.2.0.0] - 2026-04-15 + +### Added +- Cross-document synthesis: click "All" in the chat header to query across every notebook at once. Ask "what do these papers disagree about?" and get a sourced answer that names which notebook and document each claim came from. +- Source panel now shows notebook labels when in cross-notebook mode, so you can trace every answer back to its origin. +- LLM prompt explicitly instructs the model to compare, contrast, and name disagreements across sources from different notebooks. + ## [0.1.1.0] - 2026-04-15 ### Added diff --git a/VERSION b/VERSION index 482e997..e396b40 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0.0 +0.2.0.0 diff --git a/apps/desktop/src/components/chat/ChatView.tsx b/apps/desktop/src/components/chat/ChatView.tsx index ed30c94..8060e49 100644 --- a/apps/desktop/src/components/chat/ChatView.tsx +++ b/apps/desktop/src/components/chat/ChatView.tsx @@ -24,6 +24,8 @@ export function ChatView({ pendingSuggest, onSuggestConsumed }: { pendingSuggest const notebooks = useAppStore((s) => s.notebooks); const toggleSourcePanel = useAppStore((s) => s.toggleSourcePanel); const status = useAppStore((s) => s.status); + const crossNotebookMode = useAppStore((s) => s.crossNotebookMode); + const setCrossNotebookMode = useAppStore((s) => s.setCrossNotebookMode); const activeNotebook = notebooks.find((nb) => nb.notebook_id === activeNotebookId) ?? null; @@ -113,14 +115,31 @@ export function ChatView({ pendingSuggest, onSuggestConsumed }: { pendingSuggest
-

{activeNotebook ? activeNotebook.title : 'Notebook LM'}

- {activeNotebook && ( +

{crossNotebookMode ? 'All Notebooks' : (activeNotebook ? activeNotebook.title : 'Notebook LM')}

+ {crossNotebookMode ? ( + + {notebooks.length} {notebooks.length === 1 ? 'notebook' : 'notebooks'} + + ) : activeNotebook ? ( {activeNotebook.source_count} {activeNotebook.source_count === 1 ? 'doc' : 'docs'} - )} + ) : null}
+ {notebooks.length > 1 && ( + + )} {messages.length > 0 && (
- {activeSources.map((source, i) => ( -
- {source.document_name} - {source.relevance_score != null && ( -
-
-
- )} -

{source.preview}

-
- ))} + {activeSources.map((source, i) => { + const nbId = (source as Record).notebook_id as string | undefined; + const nbName = nbId ? notebooks.find((nb) => nb.notebook_id === nbId)?.title : null; + + return ( +
+ {crossNotebookMode && nbName && ( + {nbName} + )} + {source.document_name} + {source.relevance_score != null && ( +
+
+
+ )} +

{source.preview}

+
+ ); + })}
); diff --git a/apps/desktop/src/components/layout/layout.css b/apps/desktop/src/components/layout/layout.css index 488d4b0..6fbb124 100644 --- a/apps/desktop/src/components/layout/layout.css +++ b/apps/desktop/src/components/layout/layout.css @@ -441,6 +441,16 @@ box-shadow: var(--shadow-md); } +.source-card-notebook { + display: block; + font-size: 10px; + font-weight: 500; + color: var(--color-accent); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 2px; +} + .source-card-name { font-size: var(--text-sm); font-weight: 550; diff --git a/apps/desktop/src/hooks/useChat.ts b/apps/desktop/src/hooks/useChat.ts index 55ae550..ed56838 100644 --- a/apps/desktop/src/hooks/useChat.ts +++ b/apps/desktop/src/hooks/useChat.ts @@ -30,10 +30,13 @@ export function useChat() { store.setIsStreaming(true); store.setActiveSources([]); + // Cross-notebook mode: send all notebook IDs instead of just one + const crossMode = store.crossNotebookMode; const body = { prompt, history, - notebook_id: store.activeNotebookId, + notebook_id: crossMode ? null : store.activeNotebookId, + notebook_ids: crossMode ? store.notebooks.map((nb) => nb.notebook_id) : null, conversation_id: store.activeConversationId, }; diff --git a/apps/desktop/src/store/app-store.ts b/apps/desktop/src/store/app-store.ts index 59dca4b..a6782a9 100644 --- a/apps/desktop/src/store/app-store.ts +++ b/apps/desktop/src/store/app-store.ts @@ -19,6 +19,9 @@ interface AppState { conversations: Conversation[]; activeConversationId: string | null; + // Cross-notebook + crossNotebookMode: boolean; + // Documents documents: DocumentInfo[]; @@ -45,6 +48,9 @@ interface AppState { setConversations: (convs: Conversation[]) => void; setActiveConversationId: (id: string | null) => void; + // Actions — cross-notebook + setCrossNotebookMode: (val: boolean) => void; + // Actions — documents setDocuments: (docs: DocumentInfo[]) => void; @@ -64,6 +70,7 @@ export const useAppStore = create((set) => ({ isStreaming: false, conversations: [], activeConversationId: null, + crossNotebookMode: false, documents: [], sourcePanelOpen: true, activeSources: [], @@ -95,6 +102,9 @@ export const useAppStore = create((set) => ({ setConversations: (convs) => set({ conversations: convs }), setActiveConversationId: (id) => set({ activeConversationId: id }), + // Cross-notebook + setCrossNotebookMode: (val) => set({ crossNotebookMode: val }), + // Documents setDocuments: (docs) => set({ documents: docs }), diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index b2dde0c..9c48dc7 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -7,6 +7,7 @@ export interface ChatRequest { prompt: string; history?: ChatMessage[]; notebook_id?: string | null; + notebook_ids?: string[] | null; conversation_id?: string | null; } diff --git a/backend/notebooklm_backend/models/chat.py b/backend/notebooklm_backend/models/chat.py index 28db0a4..76c2d30 100644 --- a/backend/notebooklm_backend/models/chat.py +++ b/backend/notebooklm_backend/models/chat.py @@ -14,6 +14,7 @@ class ChatRequest(BaseModel): prompt: str history: List[ChatMessage] | None = None notebook_id: str | None = Field(None, description="Optional notebook ID for RAG-enabled chat") + notebook_ids: List[str] | None = Field(None, description="Optional list of notebook IDs for cross-notebook synthesis") conversation_id: str | None = Field(None, description="Optional conversation ID for persistence") diff --git a/backend/notebooklm_backend/routes/chat.py b/backend/notebooklm_backend/routes/chat.py index e564044..aadddb5 100644 --- a/backend/notebooklm_backend/routes/chat.py +++ b/backend/notebooklm_backend/routes/chat.py @@ -75,6 +75,7 @@ async def event_generator(): prompt=payload.prompt, history=history, notebook_id=payload.notebook_id, + notebook_ids=payload.notebook_ids, ): if event.get("type") == "meta": sources_data = event.get("sources") diff --git a/backend/notebooklm_backend/services/chat.py b/backend/notebooklm_backend/services/chat.py index 38b3571..4e71a4e 100644 --- a/backend/notebooklm_backend/services/chat.py +++ b/backend/notebooklm_backend/services/chat.py @@ -89,6 +89,7 @@ async def stream_reply( prompt: str, history: Iterable[ChatMessage] | None = None, notebook_id: str | None = None, + notebook_ids: list[str] | None = None, ) -> AsyncIterator[dict[str, Any]]: """ Stream tokens using SSE-friendly event payloads: @@ -101,7 +102,42 @@ async def stream_reply( sources: list[dict[str, Any]] = [] rag_context: RAGContext | None = None - if notebook_id and self._rag_service: + # Cross-notebook synthesis mode + if notebook_ids and len(notebook_ids) > 1 and self._rag_service: + try: + full_question = prompt + if history: + recent_context = "\n".join([f"{msg.role}: {msg.content}" for msg in list(history)[-3:]]) + full_question = f"Previous conversation:\n{recent_context}\n\nCurrent question: {prompt}" + context = await self._rag_service.prepare_prompt_cross_notebook( + notebook_ids=notebook_ids, + question=full_question, + top_k=20, + ) + metrics.update(context.metrics) + prompt_text = context.prompt or None + sources = self._format_sources(context.sources) + rag_context = context + if not prompt_text: + yield { + "type": "meta", + "provider": self.provider, + "sources": sources, + "metrics": metrics or None, + } + yield { + "type": "done", + "reply": "No relevant documents found across the selected notebooks.", + "metrics": metrics or None, + } + return + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Cross-notebook RAG failed: {e}", exc_info=True) + prompt_text = None + metrics = {} + elif notebook_id and self._rag_service: try: full_question = prompt if history: @@ -202,14 +238,15 @@ def _format_sources(self, sources: List[SourceAttribution]) -> list[dict[str, An if src.distance is not None: # L2 distance normalization: 0 -> 100%, unbounded -> approaches 0% relevance = round(100 / (1 + src.distance)) - formatted.append( - { - "source_path": src.source_path, - "preview": preview, - "distance": src.distance, - "relevance_score": relevance, - } - ) + entry: dict[str, Any] = { + "source_path": src.source_path, + "preview": preview, + "distance": src.distance, + "relevance_score": relevance, + } + if src.notebook_id: + entry["notebook_id"] = src.notebook_id + formatted.append(entry) return formatted def _record_metrics( diff --git a/backend/notebooklm_backend/services/rag.py b/backend/notebooklm_backend/services/rag.py index 5bc17c3..98808d1 100644 --- a/backend/notebooklm_backend/services/rag.py +++ b/backend/notebooklm_backend/services/rag.py @@ -15,6 +15,7 @@ class SourceAttribution: source_path: str content: str distance: float | None + notebook_id: str | None = None @dataclass @@ -201,3 +202,82 @@ async def query(self, notebook_id: str, question: str, top_k: int = 5) -> RAGRes metrics["total_ms"] = metrics.get("total_ms", 0.0) + metrics["llm_ms"] return RAGResponse(answer=answer, sources=context.sources, metrics=metrics) + + async def prepare_prompt_cross_notebook( + self, + notebook_ids: list[str], + question: str, + top_k: int = 20, + ) -> RAGContext: + """Prepare a prompt that retrieves and synthesizes across multiple notebooks.""" + total_start = time.perf_counter() + metrics: dict[str, float] = {} + + retrieval_start = time.perf_counter() + query_results = self.vector_store.query_across_notebooks( + notebook_ids=notebook_ids, + query=question, + top_k=top_k, + ) + metrics["retrieval_ms"] = (time.perf_counter() - retrieval_start) * 1000 + + documents = query_results.get("documents", [[]])[0] + metadatas = query_results.get("metadatas", [[]])[0] + distances = query_results.get("distances", [[]])[0] + + if not documents: + return RAGContext(prompt="", sources=[], metrics=metrics) + + # Group chunks by notebook + source file + source_groups: dict[str, list[tuple[int, str, str]]] = {} + for idx, (doc, metadata) in enumerate(zip(documents, metadatas)): + nb_id = metadata.get("notebook_id", "unknown") if isinstance(metadata, dict) else "unknown" + source_path = metadata.get("source_path", "unknown") if isinstance(metadata, dict) else "unknown" + key = f"{nb_id}::{source_path}" + if key not in source_groups: + source_groups[key] = [] + source_groups[key].append((idx, doc, nb_id)) + + # Build prompt with notebook labels + prompt_parts = [] + for key, chunks in source_groups.items(): + nb_id, source_path = key.split("::", 1) + source_name = Path(source_path).name if source_path != "unknown" else "Document" + prompt_parts.append(f"From notebook '{nb_id}', document '{source_name}':") + for idx, doc, _ in chunks: + prompt_parts.append(f" [Source {idx+1}]: {doc}") + prompt_parts.append("") + + prompt_context = "\n".join(prompt_parts) + notebook_count = len(set( + m.get("notebook_id", "?") for m in metadatas if isinstance(m, dict) + )) + + prompt = ( + "You are answering a question by synthesizing information ACROSS MULTIPLE NOTEBOOKS.\n" + f"You have access to {notebook_count} notebooks with excerpts from various documents.\n" + "Rules:\n" + "- Compare and contrast information from different notebooks and documents.\n" + "- When sources from different notebooks disagree, name the disagreement explicitly.\n" + "- When citing, reference which notebook and document the information came from.\n" + "- If the answer is not present in the excerpts, " + "reply: 'I could not find this across the provided notebooks.'\n\n" + f"Excerpts grouped by notebook and document:\n{prompt_context}\n" + f"Question: {question}\n\n" + "Answer (synthesize across all sources):" + ) + + sources = [ + SourceAttribution( + source_path=metadata.get("source_path", "unknown") if isinstance(metadata, dict) else "unknown", + content=document, + distance=distances[idx] if idx < len(distances) else None, + notebook_id=metadata.get("notebook_id") if isinstance(metadata, dict) else None, + ) + for idx, (document, metadata) in enumerate(zip(documents, metadatas)) + ] + + metrics["prep_ms"] = (time.perf_counter() - total_start) * 1000 + metrics["total_ms"] = metrics["prep_ms"] + metrics["notebooks_queried"] = float(len(notebook_ids)) + return RAGContext(prompt=prompt, sources=sources, metrics=metrics) diff --git a/backend/notebooklm_backend/services/vector_store.py b/backend/notebooklm_backend/services/vector_store.py index a68ce21..e57e535 100644 --- a/backend/notebooklm_backend/services/vector_store.py +++ b/backend/notebooklm_backend/services/vector_store.py @@ -74,6 +74,62 @@ def query( pass return collection.query(**query_kwargs) + def query_across_notebooks( + self, + notebook_ids: list[str], + query: str, + top_k: int = 5, + ) -> dict: + """ + Query multiple notebook collections and merge results ranked by distance. + Returns the same format as a single query() call but with an extra + 'notebook_id' field in each metadata entry. + """ + query_emb = self.embedding_backend.embed([query]) + + all_documents: list[str] = [] + all_metadatas: list[dict] = [] + all_distances: list[float] = [] + + for nb_id in notebook_ids: + try: + collection = self.client.get_collection(name=self._collection_name(nb_id)) + except Exception: + continue # notebook collection doesn't exist yet + + if collection.count() == 0: + continue + + result = collection.query( + query_embeddings=query_emb, + n_results=min(top_k, collection.count()), + ) + + docs = result.get("documents", [[]])[0] + metas = result.get("metadatas", [[]])[0] + dists = result.get("distances", [[]])[0] if result.get("distances") else [] + + for i, (doc, meta) in enumerate(zip(docs, metas)): + meta_with_nb = {**(meta if isinstance(meta, dict) else {}), "notebook_id": nb_id} + all_documents.append(doc) + all_metadatas.append(meta_with_nb) + all_distances.append(dists[i] if i < len(dists) else float("inf")) + + # Sort by distance (best matches first) and take top_k + if not all_documents: + return {"documents": [[]], "metadatas": [[]], "distances": [[]]} + + ranked = sorted( + zip(all_documents, all_metadatas, all_distances), + key=lambda x: x[2], + )[:top_k] + + return { + "documents": [[r[0] for r in ranked]], + "metadatas": [[r[1] for r in ranked]], + "distances": [[r[2] for r in ranked]], + } + def _doc_summaries_collection_name(self, notebook_id: str) -> str: """Get the name of the document summaries collection.""" return f"notebook_{notebook_id}_summaries" diff --git a/backend/tests/test_cross_notebook.py b/backend/tests/test_cross_notebook.py new file mode 100644 index 0000000..595b0e8 --- /dev/null +++ b/backend/tests/test_cross_notebook.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import pytest +from notebooklm_backend.config import AppConfig +from notebooklm_backend.services.vector_store import VectorStoreManager, create_vector_store +from notebooklm_backend.services.chunking import TextChunk + + +@pytest.fixture +def settings(tmp_path): + return AppConfig( + workspace_root=tmp_path, + data_dir=tmp_path / "data", + models_dir=tmp_path / "models", + index_dir=tmp_path / "indexes", + cache_dir=tmp_path / "cache", + embedding_backend="hash", + ) + + +@pytest.fixture +def store(settings): + from notebooklm_backend.services.embeddings import create_embedding_backend + embedding = create_embedding_backend(settings) + return create_vector_store(settings, embedding) + + +def _make_chunks(source_path: str, texts: list[str]) -> list[TextChunk]: + return [ + TextChunk(chunk_id=f"{source_path}-{i}", text=t, source_path=source_path, order=i) + for i, t in enumerate(texts) + ] + + +def test_query_across_notebooks_merges_results(store): + """Results from multiple notebooks are merged and ranked by distance.""" + store.add_chunks("nb1", _make_chunks("paper_a.pdf", [ + "Machine learning improves accuracy", + "Deep learning requires large datasets", + ])) + store.add_chunks("nb2", _make_chunks("paper_b.pdf", [ + "Neural networks are powerful models", + "Transfer learning reduces training time", + ])) + + result = store.query_across_notebooks( + notebook_ids=["nb1", "nb2"], + query="machine learning models", + top_k=4, + ) + + docs = result["documents"][0] + metas = result["metadatas"][0] + + assert len(docs) == 4 + # Each result should have notebook_id in metadata + nb_ids = [m["notebook_id"] for m in metas] + assert "nb1" in nb_ids + assert "nb2" in nb_ids + + +def test_query_across_notebooks_respects_top_k(store): + """Only top_k results returned even when more exist across notebooks.""" + store.add_chunks("nb1", _make_chunks("doc1.txt", ["text one", "text two", "text three"])) + store.add_chunks("nb2", _make_chunks("doc2.txt", ["text four", "text five"])) + + result = store.query_across_notebooks( + notebook_ids=["nb1", "nb2"], + query="text", + top_k=3, + ) + + assert len(result["documents"][0]) == 3 + + +def test_query_across_notebooks_empty_notebook_skipped(store): + """Empty or nonexistent notebooks don't cause errors.""" + store.add_chunks("nb1", _make_chunks("doc.txt", ["some content here"])) + + result = store.query_across_notebooks( + notebook_ids=["nb1", "nb_nonexistent"], + query="content", + top_k=5, + ) + + docs = result["documents"][0] + assert len(docs) == 1 + assert result["metadatas"][0][0]["notebook_id"] == "nb1" + + +def test_query_across_notebooks_all_empty(store): + """Returns empty results when all notebooks are empty/missing.""" + result = store.query_across_notebooks( + notebook_ids=["nb_missing_1", "nb_missing_2"], + query="anything", + top_k=5, + ) + + assert result["documents"] == [[]] + + +def test_query_across_notebooks_single_notebook_fallback(store): + """Works correctly with just one notebook (degrades gracefully).""" + store.add_chunks("nb1", _make_chunks("paper.pdf", [ + "Important finding about climate", + "Temperature data shows warming", + ])) + + result = store.query_across_notebooks( + notebook_ids=["nb1"], + query="climate temperature", + top_k=2, + ) + + docs = result["documents"][0] + assert len(docs) == 2 + assert all(m["notebook_id"] == "nb1" for m in result["metadatas"][0]) + + +def test_query_across_notebooks_results_sorted_by_distance(store): + """Results are sorted by distance (best first).""" + store.add_chunks("nb1", _make_chunks("a.txt", ["alpha text"])) + store.add_chunks("nb2", _make_chunks("b.txt", ["beta text"])) + + result = store.query_across_notebooks( + notebook_ids=["nb1", "nb2"], + query="alpha text", + top_k=2, + ) + + distances = result["distances"][0] + assert distances == sorted(distances), "Results should be sorted by ascending distance"