Files
mcp-nextcloud/nextcloud_mcp_server/models/semantic.py
T
Chris CoutinhoandClaude Opus 4.8 21b7922bac feat: replace NATS ingest with procrastinate Postgres queue (#183)
Re-architect document ingest from the shared NATS-glued document-processor to a
per-tenant, in-process model owned by nextcloud-mcp-server (Deck #183). The MCP
server now owns both sides of ingest:

- Producer (api role): the scanner defers one job per changed document into the
  app's Postgres via procrastinate (queueing_lock dedup; no execution lock, so a
  crashed worker can't deadlock a doc — Qdrant upserts are idempotent).
- Consumer (worker role): `nextcloud-mcp-server worker` drains the queue and runs
  the existing process_document pipeline; a periodic task reclaims jobs orphaned
  in `doing` by a crash.

INGEST_QUEUE selects the transport (auto: postgres when DATABASE_URL is Postgres,
else the in-process anyio queue for SQLite/dev). procrastinate manages its own
tables (applied on a fresh DB at startup and by `db upgrade`). The vector-sync
status surface reads job counts from Postgres in postgres mode. procrastinate +
psycopg3 ship in the [postgres] extra; the app's own engine still uses asyncpg
(driver unification is a follow-up handled in the rendered Helm chart).

NATS JetStream, the Postgres-queue stub, the bus status subscriber, and nats-py
are removed.

BREAKING CHANGE: the external-NATS-ingest env vars are removed
(INGEST_MODE, STATUS_BACKEND, INGEST_BUS_URL, INGEST_BUS_NUM_REPLICAS,
FACT_EVENT_EMITTER). Use INGEST_QUEUE (memory|postgres) and the `worker`
command instead. TENANT_ID is retained (no longer NATS-subject-charset-validated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:11:11 +02:00

192 lines
7.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 stringified "
"for Qdrant's keyword-indexed doc_id payload; the MCP response narrows "
"back to int via int(r.id). A future doc_type with non-numeric ids "
"would surface here as a TypeError at the narrowing boundary, "
"forcing a deliberate widening of this field rather than a silent "
"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_chunk_count: int = Field(
default=0,
description=(
"Number of search result chunks that passed verify-on-read "
"access checks (ADR-019). Equals len(verified_results) before "
"trimming to limit. Sized in chunks (result rows), NOT in "
"unique documents — see dropped_document_count for the "
"per-document counterpart."
),
)
dropped_document_count: int = Field(
default=0,
description=(
"Number of unique (doc_id, doc_type) pairs dropped as ghost "
"records during verify-on-read (ADR-019). A short result page "
"(len(results) < limit) combined with a non-zero "
"dropped_document_count indicates ghost density rather than "
"scarcity of relevant content. Note: this counter is sized in "
"unique documents while verified_chunk_count is sized in "
"chunks — a single document can contribute multiple chunks, "
"so subtracting dropped_document_count from "
"verified_chunk_count is NOT a meaningful operation."
),
)
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")
ingest_queue: str | None = Field(
default=None,
description='Ingest queue backend: "memory" or "postgres" (Deck #183)',
)
job_counts: dict[str, int] | None = Field(
default=None,
description=(
"Per-status ingest job counts (todo/doing/failed/…) on the postgres "
"queue backend; None on the in-memory backend"
),
)
__all__ = [
"SemanticSearchResult",
"SemanticSearchResponse",
"SamplingSearchResponse",
"VectorSyncStatusResponse",
]