from __future__ import annotations from typing import Protocol from .models import ParentDocument, SearchHit class QueryEmbeddingUnavailable(RuntimeError): """The similarity route's embedding provider could not be reached. Raised by an adapter and caught by the domain, which abstains. It exists so a provider outage refuses to answer instead of returning a 500: an unreachable embedder means the question was never actually searched, and an error page hides that from the caller just as effectively as a wrong answer would. The domain catches this without importing any SDK. """ class AnswerGenerationUnavailable(RuntimeError): """The answer generator could not be reached. Same contract as `QueryEmbeddingUnavailable`: the adapter translates its SDK's failure into this, and the domain degrades to the extractive answer rather than returning an error. Generation is a presentation improvement over quoting the source; losing it must never lose the answer. """ class RerankUnavailable(RuntimeError): """The reranker could not be reached. Same fail-open contract as the other provider errors, but softer: losing the reranker only means the candidates keep their original order, so the caller catches this and proceeds rather than abstaining. Rerank is an ordering improvement on the fallback, never a precondition for an answer. """ class Reranker(Protocol): """Reorders candidate texts by joint relevance to the query. Returns indices into `documents`, most relevant first. A cross-encoder pass that recovers precision the bi-encoder embedding cannot; applied only to the similarity/overview fallback, never to the deterministic section route. """ def rerank(self, query: str, documents: list[str], top_n: int | None = None) -> list[int]: ... class AnswerGenerator(Protocol): """Rewrites retrieved evidence into prose. Never a source of facts. Whatever it returns is checked by `rag.grounding.verify` before a caller sees it, so this port carries no trust: an implementation that fabricates a dose produces a discarded generation, not a wrong answer. """ def generate(self, system: str, user: str, schema: dict) -> str: ... class Retriever(Protocol): def search(self, query: str, drug_id: str, limit: int) -> list[SearchHit]: ... class SectionRetriever(Protocol): """Exact retrieval of one whole section, with no similarity involved. Separate from `Retriever` so a store that cannot filter by payload is still a valid `Retriever` (interface segregation). `find_by_section` must return **every** part of the section: a partial contraindication list reads as a complete one, which is worse than returning nothing. """ def find_by_section(self, drug_id: str, section_key: str) -> list[SearchHit]: ... class ParentStore(Protocol): def get(self, parent_id: str) -> ParentDocument | None: ... class SectionListRetriever(Protocol): """The per-drug section checklist Feature-List #4 needs, cheap because `drug_id`/`section_key` are already-indexed Qdrant payload fields — no new indexing. Separate from `SectionRetriever` (interface segregation): this returns labels only, never chunk text.""" def list_sections(self, drug_id: str) -> list[tuple[str, str]]: ...