Files
duocthu/apps/ai-service/rag/ports.py
T

79 lines
2.9 KiB
Python

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: ...