3648d478f1
Previously, an empty query string to nc_notes_search_notes would return zero results due to an early return when no query tokens were present. This was counterintuitive - users expect an empty query to list all notes, not return nothing. Changes: - Modified NotesSearchController.search_notes() to return all notes when query is empty - Added documentation to clarify this behavior - Empty query results have _score: None (no relevance scoring) - Non-empty query results continue to have relevance scores Fixes behavior where listing all notes was impossible via the search tool. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
114 lines
3.8 KiB
Python
114 lines
3.8 KiB
Python
"""Controller for notes search functionality."""
|
|
|
|
from typing import Any, AsyncIterable, Dict, List
|
|
|
|
|
|
class NotesSearchController:
|
|
"""Handles notes search logic and scoring."""
|
|
|
|
async def search_notes(
|
|
self, notes: AsyncIterable[Dict[str, Any]], query: str
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Search notes using token-based matching with relevance ranking.
|
|
Returns notes sorted by relevance score.
|
|
If query is empty, returns all notes.
|
|
"""
|
|
search_results = []
|
|
query_tokens = self._process_query(query)
|
|
|
|
# If empty query after processing, return all notes
|
|
if not query_tokens:
|
|
async for note in notes:
|
|
search_results.append(
|
|
{
|
|
"id": note.get("id"),
|
|
"title": note.get("title"),
|
|
"category": note.get("category"),
|
|
"modified": note.get("modified"),
|
|
"_score": None, # No score for unfiltered results
|
|
}
|
|
)
|
|
return search_results
|
|
|
|
# Process and score each note
|
|
async for note in notes:
|
|
title_tokens, content_tokens = self._process_note_content(note)
|
|
score = self._calculate_score(query_tokens, title_tokens, content_tokens)
|
|
|
|
# Only include notes with a non-zero score
|
|
if score >= 0.5:
|
|
search_results.append(
|
|
{
|
|
"id": note.get("id"),
|
|
"title": note.get("title"),
|
|
"category": note.get("category"),
|
|
"modified": note.get("modified"),
|
|
"_score": score, # Include score for sorting
|
|
}
|
|
)
|
|
|
|
# Sort by score in descending order
|
|
search_results.sort(key=lambda x: x["_score"], reverse=True)
|
|
|
|
return search_results
|
|
|
|
def _process_query(self, query: str) -> List[str]:
|
|
"""
|
|
Tokenize and normalize the search query.
|
|
"""
|
|
# Convert to lowercase and split into tokens
|
|
tokens = query.lower().split()
|
|
# Filter out very short tokens
|
|
tokens = [token for token in tokens if len(token) > 1]
|
|
return tokens
|
|
|
|
def _process_note_content(
|
|
self, note: Dict[str, Any]
|
|
) -> tuple[List[str], List[str]]:
|
|
"""
|
|
Tokenize and normalize note title and content.
|
|
"""
|
|
# Process title
|
|
title = note.get("title", "").lower()
|
|
title_tokens = title.split()
|
|
|
|
# Process content
|
|
content = note.get("content", "").lower()
|
|
content_tokens = content.split()
|
|
|
|
return title_tokens, content_tokens
|
|
|
|
def _calculate_score(
|
|
self,
|
|
query_tokens: List[str],
|
|
title_tokens: List[str],
|
|
content_tokens: List[str],
|
|
) -> float:
|
|
"""
|
|
Calculate a relevance score for a note based on query tokens.
|
|
"""
|
|
# Constants for weighting
|
|
TITLE_WEIGHT = 3.0
|
|
CONTENT_WEIGHT = 1.0
|
|
|
|
score = 0.0
|
|
|
|
# Count matches in title
|
|
title_matches = sum(1 for qt in query_tokens if qt in title_tokens)
|
|
if query_tokens: # Avoid division by zero
|
|
title_match_ratio = title_matches / len(query_tokens)
|
|
score += TITLE_WEIGHT * title_match_ratio
|
|
|
|
# Count matches in content
|
|
content_matches = sum(1 for qt in query_tokens if qt in content_tokens)
|
|
if query_tokens: # Avoid division by zero
|
|
content_match_ratio = content_matches / len(query_tokens)
|
|
score += CONTENT_WEIGHT * content_match_ratio
|
|
|
|
# If no tokens matched at all, return zero
|
|
if title_matches == 0 and content_matches == 0:
|
|
return 0.0
|
|
|
|
return score
|