Files
mcp-nextcloud/nextcloud_mcp_server/models/semantic.py
T
Chris CoutinhoandClaude Opus 4.7 e8df6003c5 refactor(search): address PR #750 round 6 review feedback
Closes out the remaining nits flagged in the round-6 review.

Critical:
- _verify_files contract comment now enumerates all None-return cases
  (404 + malformed PROPFIND XML) and documents the false-eviction
  trade-off; self-healing via re-indexing recovers
- int(r.id) cast at the SemanticSearchResult boundary now raises a
  TypeError with explicit doc_type/value context instead of bubbling
  up as an opaque "Search failed: ..." McpError

Design observations:
- nc_semantic_search_answer docstring documents the per-note
  round-trip cost from the post-verification race guard
- News verification latency hint added to configuration.md
- SemanticSearchResponse exposes verified_count + dropped_count so
  short result pages on high-ghost-density indexes are
  distinguishable from genuine scarcity. verify_search_results now
  returns (kept, dropped_count); production caller and tests updated

Minor:
- Comment clarifies the .get() fallback in verify_search_results is
  defensive only (run_verifier always populates the entry)
- Eviction task-group guard narrowed from except Exception to
  except RuntimeError (the only documented failure mode of
  TaskGroup.start_soon on a closed group)
- Indexer logs a warning when a deck_card task is missing
  board_id/stack_id, surfacing data-quality issues at index time
  rather than at verification time
- New unit test covers the news verifier's non-numeric-id fail-open
  path (one bad doc_id keeps the entire batch)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:02:27 +02:00

171 lines
6.5 KiB
Python

"""Pydantic models for semantic search responses."""
from pydantic import BaseModel, Field
from .base import BaseResponse
class SemanticSearchResult(BaseModel):
"""Model for semantic search results with additional metadata."""
id: int = Field(
description=(
"Document ID. Numeric for all currently indexed types (notes, files, "
"deck cards, news items). The internal SearchResult.id is typed as "
"int|str to leave room for future doc types with string identifiers; "
"the MCP response narrows to int and a future widening here would be "
"a deliberate, breaking-by-design API change."
)
)
doc_type: str = Field(
description="Document type (note, calendar_event, deck_card, etc.)"
)
title: str = Field(description="Document title")
category: str = Field(
default="", description="Document category (notes) or location (calendar)"
)
excerpt: str = Field(description="Excerpt from matching chunk")
score: float = Field(
description=(
"Relevance score (≥ 0.0, higher is better). "
"Score range depends on fusion method: "
"RRF produces scores in [0.0, 1.0], "
"DBSF can exceed 1.0 (sum of normalized scores from multiple systems)"
)
)
chunk_index: int = Field(description="Index of matching chunk in document")
total_chunks: int = Field(description="Total number of chunks in document")
chunk_start_offset: int | None = Field(
default=None, description="Character position where chunk starts in document"
)
chunk_end_offset: int | None = Field(
default=None, description="Character position where chunk ends in document"
)
page_number: int | None = Field(
default=None, description="Page number for PDF documents"
)
page_count: int | None = Field(
default=None, description="Total number of pages in PDF document"
)
# Context expansion fields (optional, populated when include_context=True)
has_context_expansion: bool = Field(
default=False, description="Whether context expansion was performed"
)
marked_text: str | None = Field(
default=None,
description="Full text with position markers around matched chunk",
)
before_context: str | None = Field(
default=None, description="Text before the matched chunk"
)
after_context: str | None = Field(
default=None, description="Text after the matched chunk"
)
has_before_truncation: bool | None = Field(
default=None, description="Whether before_context was truncated"
)
has_after_truncation: bool | None = Field(
default=None, description="Whether after_context was truncated"
)
class SemanticSearchResponse(BaseResponse):
"""Response model for semantic search across all indexed Nextcloud apps."""
results: list[SemanticSearchResult] = Field(
description="Semantic search results with similarity scores"
)
query: str = Field(description="The search query used")
total_found: int = Field(description="Total number of documents found")
search_method: str = Field(
default="semantic", description="Search method used (semantic or hybrid)"
)
verified_count: int = Field(
default=0,
description=(
"Number of unique documents that passed verify-on-read access "
"checks (ADR-019). Equals len(results) before trimming to limit."
),
)
dropped_count: int = Field(
default=0,
description=(
"Number of unique documents dropped as ghost records during "
"verify-on-read (ADR-019). A short result page (len(results) < "
"limit) combined with a non-zero dropped_count indicates ghost "
"density rather than scarcity of relevant content."
),
)
class SamplingSearchResponse(BaseResponse):
"""Response from semantic search with LLM-generated answer via MCP sampling.
This response includes both a generated natural language answer (created by
the MCP client's LLM via sampling) and the source documents used to generate
that answer. Users can read the answer for quick information and review
sources for verification and deeper exploration.
Attributes:
query: The original user query
generated_answer: Natural language answer generated by client's LLM
sources: List of semantic search results used as context
total_found: Total number of matching documents found
search_method: Always "semantic_sampling" for this response type
model_used: Name of model that generated the answer (e.g., "claude-3-5-sonnet")
stop_reason: Why generation stopped ("endTurn", "maxTokens", etc.)
"""
query: str = Field(..., description="Original user query")
generated_answer: str = Field(
..., description="LLM-generated answer based on retrieved documents"
)
sources: list[SemanticSearchResult] = Field(
default_factory=list,
description="Source documents with excerpts and relevance scores",
)
total_found: int = Field(..., description="Total matching documents")
search_method: str = Field(
default="semantic_sampling", description="Search method used"
)
model_used: str | None = Field(
default=None, description="Model that generated the answer"
)
stop_reason: str | None = Field(
default=None, description="Reason generation stopped"
)
class VectorSyncStatusResponse(BaseResponse):
"""Response for vector sync status.
Provides information about the current state of vector sync,
including how many documents are indexed and how many are pending.
Attributes:
indexed_count: Number of documents in Qdrant vector database
pending_count: Number of documents in processing queue
status: Current sync status ("idle" or "syncing")
enabled: Whether vector sync is enabled
"""
indexed_count: int = Field(
default=0, description="Number of documents indexed in vector database"
)
pending_count: int = Field(
default=0, description="Number of documents pending processing"
)
status: str = Field(
default="disabled",
description='Sync status: "idle", "syncing", or "disabled"',
)
enabled: bool = Field(default=False, description="Whether vector sync is enabled")
__all__ = [
"SemanticSearchResult",
"SemanticSearchResponse",
"SamplingSearchResponse",
"VectorSyncStatusResponse",
]