Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,35 @@ class RecentlyViewedResponse(BaseModel):


# Search models
class SearchRequest(BaseModel):
class NotebookScopeMixin(BaseModel):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Vision & principles alignment

This PR introduces a new shared API and data-layer scoping contract without the required decision record. Add a short ADR or PDR in docs/7-DEVELOPMENT/decisions/ covering the notebook-scope semantics, compatibility choice, and consequences.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/models.py, line 39:

<comment>This PR introduces a new shared API and data-layer scoping contract without the required decision record. Add a short ADR or PDR in `docs/7-DEVELOPMENT/decisions/` covering the notebook-scope semantics, compatibility choice, and consequences.</comment>

<file context>
@@ -36,7 +36,35 @@ class RecentlyViewedResponse(BaseModel):
 
 # Search models
-class SearchRequest(BaseModel):
+class NotebookScopeMixin(BaseModel):
+    """Optional notebook scope shared by Search and Ask requests (#574, #87).
+
</file context>

"""Optional notebook scope shared by Search and Ask requests (#574, #87).

Both `notebook_id` (single, the shape #574 proposed and existing clients
already send) and `notebook_ids` (several) are accepted; `scope_notebook_ids`
merges them. An empty scope means the whole knowledge base.
"""

notebook_id: Optional[str] = Field(
None, description="Restrict results to a single notebook"
)
notebook_ids: Optional[List[str]] = Field(
None,
max_length=50,
description="Restrict results to these notebooks (omit or empty for all)",
)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

@property
def scope_notebook_ids(self) -> List[str]:
# Keep empty strings so validation rejects them instead of silently
# widening the scope to the whole knowledge base.
merged: List[str] = []
for nb_id in [self.notebook_id, *(self.notebook_ids or [])]:
if nb_id is not None and nb_id not in merged:
merged.append(nb_id)
return merged


class SearchRequest(NotebookScopeMixin):
query: str = Field(..., description="Search query")
type: Literal["text", "vector"] = Field("text", description="Search type")
limit: int = Field(100, description="Maximum number of results", ge=1, le=1000)
Expand All @@ -53,7 +81,7 @@ class SearchResponse(BaseModel):
search_type: str = Field(..., description="Type of search performed")


class AskRequest(BaseModel):
class AskRequest(NotebookScopeMixin):
question: str = Field(..., description="Question to ask the knowledge base")
strategy_model: str = Field(..., description="Model ID for query strategy")
answer_model: str = Field(..., description="Model ID for individual answers")
Expand Down
36 changes: 30 additions & 6 deletions api/routers/search.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import json
from typing import AsyncGenerator
from typing import AsyncGenerator, List

from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from loguru import logger

from api.models import AskRequest, AskResponse, SearchRequest, SearchResponse
from open_notebook.ai.models import Model, model_manager
from open_notebook.domain.notebook import text_search, vector_search
from open_notebook.domain.notebook import (
resolve_notebook_scope,
text_search,
vector_search,
)
from open_notebook.exceptions import (
DatabaseOperationError,
InvalidInputError,
Expand All @@ -22,6 +26,8 @@
async def search_knowledge_base(search_request: SearchRequest):
"""Search the knowledge base using text or vector search."""
try:
notebook_ids = await resolve_notebook_scope(search_request.scope_notebook_ids)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Security & testability

When scope validation hits a database or driver error, resolve_notebook_scope() lets the raw exception reach handlers that return str(e) in the 500 detail. Convert the lookup failure to a typed error and return a sanitized message capped at 200 characters while logging the traceback server-side.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/routers/search.py, line 29:

<comment>When scope validation hits a database or driver error, `resolve_notebook_scope()` lets the raw exception reach handlers that return `str(e)` in the 500 detail. Convert the lookup failure to a typed error and return a sanitized message capped at 200 characters while logging the traceback server-side.</comment>

<file context>
@@ -22,6 +26,8 @@
 async def search_knowledge_base(search_request: SearchRequest):
     """Search the knowledge base using text or vector search."""
     try:
+        notebook_ids = await resolve_notebook_scope(search_request.scope_notebook_ids)
+
         if search_request.type == "vector":
</file context>


if search_request.type == "vector":
# Check if embedding model is available for vector search
if not await model_manager.get_embedding_model():
Expand All @@ -36,6 +42,7 @@ async def search_knowledge_base(search_request: SearchRequest):
source=search_request.search_sources,
note=search_request.search_notes,
minimum_score=search_request.minimum_score,
notebook_ids=notebook_ids,
)
else:
# Text search
Expand All @@ -44,6 +51,7 @@ async def search_knowledge_base(search_request: SearchRequest):
results=search_request.limit,
source=search_request.search_sources,
note=search_request.search_notes,
notebook_ids=notebook_ids,
)

return SearchResponse(
Expand All @@ -67,7 +75,11 @@ async def search_knowledge_base(search_request: SearchRequest):


async def stream_ask_response(
question: str, strategy_model: Model, answer_model: Model, final_answer_model: Model
question: str,
strategy_model: Model,
answer_model: Model,
final_answer_model: Model,
notebook_ids: List[str],
) -> AsyncGenerator[str, None]:
"""Stream the ask response as Server-Sent Events."""
try:
Expand All @@ -76,7 +88,7 @@ async def stream_ask_response(
# LangGraph accepts a partial state dict at runtime, but its typed
# overloads require the full state type (langgraph typing limitation).
async for chunk in ask_graph.astream( # type: ignore[call-overload]
input=dict(question=question),
input=dict(question=question, notebook_ids=notebook_ids),
config=dict(
configurable=dict(
strategy_model=strategy_model.id,
Expand Down Expand Up @@ -124,6 +136,10 @@ async def stream_ask_response(
async def ask_knowledge_base(ask_request: AskRequest):
"""Ask the knowledge base a question using AI models."""
try:
# Cheapest check first: a malformed or unknown scope fails before any
# model lookup or embedding check can mask it.
notebook_ids = await resolve_notebook_scope(ask_request.scope_notebook_ids)

# Validate models exist
strategy_model = await Model.get(ask_request.strategy_model)
answer_model = await Model.get(ask_request.answer_model)
Expand Down Expand Up @@ -155,7 +171,11 @@ async def ask_knowledge_base(ask_request: AskRequest):
# For streaming response
return StreamingResponse(
stream_ask_response(
ask_request.question, strategy_model, answer_model, final_answer_model
ask_request.question,
strategy_model,
answer_model,
final_answer_model,
notebook_ids,
),
media_type="text/event-stream",
headers={
Expand All @@ -178,6 +198,10 @@ async def ask_knowledge_base(ask_request: AskRequest):
async def ask_knowledge_base_simple(ask_request: AskRequest):
"""Ask the knowledge base a question and return a simple response (non-streaming)."""
try:
# Cheapest check first: a malformed or unknown scope fails before any
# model lookup or embedding check can mask it.
notebook_ids = await resolve_notebook_scope(ask_request.scope_notebook_ids)

# Validate models exist
strategy_model = await Model.get(ask_request.strategy_model)
answer_model = await Model.get(ask_request.answer_model)
Expand Down Expand Up @@ -211,7 +235,7 @@ async def ask_knowledge_base_simple(ask_request: AskRequest):
# LangGraph accepts a partial state dict at runtime, but its typed
# overloads require the full state type (langgraph typing limitation).
async for chunk in ask_graph.astream( # type: ignore[call-overload]
input=dict(question=ask_request.question),
input=dict(question=ask_request.question, notebook_ids=notebook_ids),
config=dict(
configurable=dict(
strategy_model=strategy_model.id,
Expand Down
16 changes: 16 additions & 0 deletions docs/3-USER-GUIDE/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,21 @@ Results show:
- Different sources mixed together
```

### Scoping to Notebooks

By default Search and Ask look at your **whole knowledge base** — every source and note in every notebook. To narrow the scope, open the **Notebooks** selector above the search options and check one or more notebooks:

```
1. Open the Notebooks selector (it reads "All notebooks" until you pick one)
2. Check the notebook(s) you want to search
3. Run your Search or Ask as usual
4. Click "Clear" to go back to the whole knowledge base
```

The scope is shared by the Search and Ask tabs, so a question asked right after a scoped search uses the same notebooks. Only sources and notes linked to the selected notebooks are considered; a source that lives in several notebooks matches when any of them is selected.

For API clients, pass `notebook_ids` (a list) or `notebook_id` (a single id) to `POST /api/search` and `POST /api/search/ask`. Omitting both keeps the global behavior. An id that does not name an existing notebook returns `404`.

---

## The Ask Feature (Automated Search)
Expand Down Expand Up @@ -295,6 +310,7 @@ Result: Comprehensive answer, not just search results
| Problem | Cause | Solution |
|---------|-------|----------|
| 1000+ results | Search too broad | Be more specific |
| | All notebooks | Scope the search to one or a few notebooks |
| | All sources | Filter by source |
| | Keyword matches rare words | Use vector search instead |

Expand Down
3 changes: 2 additions & 1 deletion docs/7-DEVELOPMENT/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ Instead of memorizing endpoints, use the interactive API docs:

**Search** - Find content by text or semantic similarity
- `POST /search` - Full-text or vector search
- `POST /ask` - Ask a question (search + synthesize)
- `POST /search/ask` - Ask a question (search + synthesize)
- Both accept an optional `notebook_ids` list (or a single `notebook_id`) to scope results to specific notebooks; omit for the whole knowledge base

**Transformations** - Custom prompts for extracting insights
- `GET/POST /transformations` - Create custom extraction rules
Expand Down
34 changes: 34 additions & 0 deletions docs/7-DEVELOPMENT/decisions/ADR-008-notebook-scoped-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# ADR-008: Notebook scope is an optional filter on the existing search functions

- **Status**: Accepted
- **Date**: 2026-09
- **Related**: #574 and #87 (the two requests this resolves), [#1315](https://github.com/lfnovo/open-notebook/discussions/1315) (Evidence-Centered Research — the larger retrieval contract), [ADR-006](ADR-006-migration-granularity.md) (migration granularity)

## Context

Search and Ask always ran against the whole knowledge base, and `notebook_id` was accepted by `POST /api/search` but silently ignored because the request model never declared it. Two long-standing requests (#574, API; #87, UI) asked for the same thing: limit Search and Ask to one or more notebooks. Discussion #1315 places scoped retrieval inside a broader design — a retrieval contract shared by Search, Ask and chat, an evidence bundle, validated citations. That design is deliberated until October 2026 and will land in stages, so the question was whether to wait for it or ship the filter now.

## Decision

**Ship notebook scope as an optional, backward-compatible filter on the existing SurrealQL search functions, exposed on both endpoints and the Search/Ask page. Treat it as the first, smallest block of #1315, not a competing design.**

Semantics:

- The scope is a **set of notebook ids**. Empty or absent means the whole knowledge base — the historical behavior, unchanged.
- A source matches when it is linked to **any** selected notebook (`reference` edge); a note when linked via the `artifact` edge. Source chunks and insights follow their parent source. Membership is resolved once per call, not per row.
- The scope lives **in the database functions** (`fn::text_search`, `fn::vector_search`) as a trailing `option<array<record<notebook>>>` parameter, so text search, vector search and every Ask fan-out apply the same rule. The previous call shapes still work.
- The API accepts both `notebook_ids` (list, at most 50) and `notebook_id` (single — the shape #574 proposed and clients already send); they are merged. Ids are validated in the domain layer before the query: malformed → 400, unknown → 404, so a typo never looks like "no matches".
- Search and Ask on the page share one selector and one scope state.

## Alternatives considered

- **Wait for the #1315 retrieval contract** — rejected: the need is confirmed and years old, and the filter is orthogonal to how evidence is bundled or cited. The contract will consume the same `notebook_ids` parameter.
- **Filter in Python after the query** — rejected: the SurrealQL functions apply `LIMIT` before returning, so post-filtering would starve scoped results and waste the FTS/vector work.
- **Single `notebook_id` only** — rejected: #87 asked for multi-select, and a list subsumes the single case. The single field stays as a convenience for existing clients.
- **Per-notebook embedding tables** (proposed on #87) — rejected: an index-layout change for a filter; out of scope, and #1315 may revisit indexing on its own terms.

## Consequences

- Adding "notebook plus specific sources" or other scope dimensions later means extending the parameter list of the same functions (new migration), not a new engine.
- Any new caller of the search functions can ignore the scope and keep global behavior; callers that need scope pass the same list. Chat context (#1315 "Auto" option) can reuse `resolve_notebook_scope` and the functions as-is.
- The migration follows ADR-006: one migration (24), with a `_down` restoring the previous definitions.
1 change: 1 addition & 0 deletions docs/7-DEVELOPMENT/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,6 @@ What this makes easier, what it makes harder, what to watch. (bullets)
| [ADR-005](ADR-005-release-confidence-process.md) | Releases pass a risk-based confidence process, gated on the real image | Accepted |
| [ADR-006](ADR-006-migration-granularity.md) | Migration granularity follows merge granularity, not release granularity | Accepted |
| [ADR-007](ADR-007-optin-runtimes.md) | Heavy extraction runtimes (Docling, Crawl4AI local) are opt-in, installed at startup | Accepted |
| [ADR-008](ADR-008-notebook-scoped-search.md) | Notebook scope is an optional filter on the existing search functions | Accepted |
| [PDR-001](PDR-001-single-user-first.md) | Single-user first; don't preclude multi-user | Accepted |
| [PDR-002](PDR-002-provider-agnostic-core.md) | Provider-agnostic core by default | Accepted |
27 changes: 23 additions & 4 deletions frontend/src/app/(dashboard)/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { StreamingResponse } from '@/components/search/StreamingResponse'
import { AdvancedModelsDialog } from '@/components/search/AdvancedModelsDialog'
import { SaveToNotebooksDialog } from '@/components/search/SaveToNotebooksDialog'
import { NotebookScopeSelector } from '@/components/search/NotebookScopeSelector'

export default function SearchPage() {
const { t } = useTranslation()
Expand All @@ -43,6 +44,9 @@ export default function SearchPage() {
const [searchSources, setSearchSources] = useState(true)
const [searchNotes, setSearchNotes] = useState(true)

// Notebook scope shared by Ask and Search; empty = whole knowledge base (#574, #87)
const [scopeNotebookIds, setScopeNotebookIds] = useState<string[]>([])

// Ask state
const [askQuestion, setAskQuestion] = useState(urlMode === 'ask' ? urlQuery : '')

Expand Down Expand Up @@ -91,9 +95,10 @@ export default function SearchPage() {
limit: 100,
search_sources: searchSources,
search_notes: searchNotes,
minimum_score: 0.2
minimum_score: 0.2,
...(scopeNotebookIds.length > 0 ? { notebook_ids: scopeNotebookIds } : {})
})
}, [searchQuery, searchType, searchSources, searchNotes, searchMutation])
}, [searchQuery, searchType, searchSources, searchNotes, scopeNotebookIds, searchMutation])

const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
Expand All @@ -110,8 +115,8 @@ export default function SearchPage() {
finalAnswer: modelDefaults.default_chat_model
}

ask.sendAsk(askQuestion, models)
}, [askQuestion, modelDefaults, customModels, ask])
ask.sendAsk(askQuestion, models, { notebookIds: scopeNotebookIds })
}, [askQuestion, modelDefaults, customModels, scopeNotebookIds, ask])

// Auto-trigger search/ask when arriving with URL params
useEffect(() => {
Expand Down Expand Up @@ -208,6 +213,13 @@ export default function SearchPage() {
<p className="text-xs text-muted-foreground">{t('searchPage.pressToSubmit')}</p>
</div>

{/* Notebook scope */}
<NotebookScopeSelector
selectedIds={scopeNotebookIds}
onChange={setScopeNotebookIds}
disabled={ask.isStreaming}
/>

{/* Models Display */}
{!hasEmbeddingModel ? (
<div className="flex items-center gap-2 p-3 text-sm text-warn bg-warn-tint rounded-md">
Expand Down Expand Up @@ -359,6 +371,13 @@ export default function SearchPage() {

{/* Search Options */}
<div className="space-y-4">
{/* Notebook scope */}
<NotebookScopeSelector
selectedIds={scopeNotebookIds}
onChange={setScopeNotebookIds}
disabled={searchMutation.isPending}
/>

{/* Search Type */}
<div className="space-y-2" role="group" aria-labelledby="search-type-label">
<span id="search-type-label" className="text-sm font-medium leading-none">{t('searchPage.searchType')}</span>
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/components/search/NotebookScopeSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NotebookScopeSelector } from './NotebookScopeSelector'
import { useNotebooks } from '@/lib/hooks/use-notebooks'

// useTranslation is mocked globally in setup.ts (t returns the key string)

vi.mock('@/lib/hooks/use-notebooks', () => ({
useNotebooks: vi.fn(),
}))

const mockUseNotebooks = vi.mocked(useNotebooks)

const notebooks = [
{ id: 'notebook:a', name: 'Alpha', description: '' },
{ id: 'notebook:b', name: 'Beta', description: 'second' },
]

describe('NotebookScopeSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseNotebooks.mockReturnValue({ data: notebooks, isLoading: false } as ReturnType<typeof useNotebooks>)
})

it('reads as "all notebooks" when nothing is selected and hides the clear action', () => {
render(<NotebookScopeSelector selectedIds={[]} onChange={vi.fn()} />)
expect(screen.getByText('searchPage.scopeAllNotebooks')).toBeInTheDocument()
expect(screen.queryByText('searchPage.scopeClear')).not.toBeInTheDocument()
})

it('adds a notebook to the scope when its checkbox is toggled', () => {
const onChange = vi.fn()
render(<NotebookScopeSelector selectedIds={['notebook:a']} onChange={onChange} />)

fireEvent.click(screen.getByRole('button', { name: /searchPage.scopeNotebooks/ }))
fireEvent.click(screen.getByRole('checkbox', { name: /Beta/ }))

expect(onChange).toHaveBeenCalledWith(['notebook:a', 'notebook:b'])
})

it('removes an already selected notebook when toggled again', () => {
const onChange = vi.fn()
render(<NotebookScopeSelector selectedIds={['notebook:a', 'notebook:b']} onChange={onChange} />)

fireEvent.click(screen.getByRole('button', { name: /searchPage.scopeNotebooks/ }))
fireEvent.click(screen.getByRole('checkbox', { name: /Alpha/ }))

expect(onChange).toHaveBeenCalledWith(['notebook:b'])
})

it('clears the whole scope from the clear action', () => {
const onChange = vi.fn()
render(<NotebookScopeSelector selectedIds={['notebook:a']} onChange={onChange} />)

fireEvent.click(screen.getByText('searchPage.scopeClear'))

expect(onChange).toHaveBeenCalledWith([])
})
})
Loading
Loading