Checkpoint frontend UI/UX overhaul and ingestion embed benchmark work
This commit is contained in:
@@ -2,22 +2,51 @@ from __future__ import annotations
|
||||
|
||||
from qdrant_client import QdrantClient
|
||||
|
||||
from adapters.embedding import (
|
||||
BedrockCohereQueryEmbedder,
|
||||
LocalHashQueryEmbedder,
|
||||
SectionOnlyQueryEmbedder,
|
||||
)
|
||||
from adapters.embedding import BedrockCohereQueryEmbedder
|
||||
from adapters.postgres import PostgresTraceRepository
|
||||
from adapters.qdrant import QdrantParentStore, QdrantRetriever
|
||||
from config import Settings
|
||||
from rag.agent import RagAgent
|
||||
from rag.answer import GroundedAnswerService
|
||||
from rag.artifacts import load_aliases
|
||||
from rag.conversation import DeterministicSummariser, InMemoryConversationStore
|
||||
from rag.conversational import ConversationalLoopService
|
||||
from rag.manifest import MANIFEST_POINT_ID, check_manifest, manifest_collection
|
||||
from rag.metrics import NullMetrics
|
||||
from rag.routing import CatalogDrugResolver, QueryRoutingService
|
||||
from rag.sections import SectionResolver
|
||||
from rag.service import EvidencePolicy, RetrievalService
|
||||
from rag.understanding import LlmQueryUnderstander
|
||||
|
||||
# How many aliases to show per candidate drug (F-04 bounds *which* drugs are
|
||||
# shown at all, per turn; this bounds how many names each shown one gets).
|
||||
_CATALOG_ALIASES_PER_DRUG = 3
|
||||
|
||||
|
||||
def _catalog_names(aliases: dict[str, set[str]]) -> dict[str, str]:
|
||||
"""The name(s) shown to the LLM for each drug_id.
|
||||
|
||||
Found live 2026-08-06: picking the first 3 aliases *alphabetically* could
|
||||
drop the drug's own canonical/INN name entirely — paracetamol has 191
|
||||
aliases (mostly trade names), and the alphabetically-first 3 were
|
||||
"0Frezefev, ABAB, Ace kid 80", none recognizable. Mid-conversation, once
|
||||
the current turn no longer restates the drug name in raw text, the model
|
||||
has to re-derive it from history + this catalog line alone — with no
|
||||
recognizable name shown, it read the earlier "paracetamol" mention as an
|
||||
unknown drug and answered "not found in the formulary" for a drug that
|
||||
plainly is. Fixed by always showing the drug_id's own name form first
|
||||
(guaranteed present, always recognizable — it's the exact string a user
|
||||
who names a drug is most likely to type), then filling remaining slots
|
||||
with short, ALL-CAPS-preferring aliases (the book's own heading
|
||||
convention, so usually the generic name, not a dosage-suffixed brand
|
||||
like "Ace kid 80").
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
for drug_id, names in aliases.items():
|
||||
canonical = drug_id.replace("_", " ")
|
||||
ranked = sorted(names, key=lambda n: (not n.isupper(), len(n), n))
|
||||
extra = [n for n in ranked if n.strip().casefold() != canonical]
|
||||
shown = [canonical, *extra][:_CATALOG_ALIASES_PER_DRUG]
|
||||
result[drug_id] = ", ".join(dict.fromkeys(shown))
|
||||
return result
|
||||
|
||||
|
||||
def _build_metrics(settings: Settings):
|
||||
@@ -73,15 +102,37 @@ def _build_reranker(settings: Settings):
|
||||
return BedrockCohereReranker(region=settings.aws_region)
|
||||
|
||||
|
||||
def _verify_corpus_manifest(client, collection: str, embedder, settings: Settings) -> None:
|
||||
"""Read the sidecar manifest point the ingestion loader writes
|
||||
(`ingestion/ingestion/load/manifest.py`) and refuse to start on a
|
||||
mismatch. Raises `rag.manifest.ManifestMismatch` (a `RuntimeError`),
|
||||
which crashes startup — a deliberate refusal, not an oversight: this
|
||||
runs at process start (`main.py` calls `build_runtime` at import time),
|
||||
so a mismatch here means the process never comes up and never serves a
|
||||
query from a corpus it wasn't verified against.
|
||||
"""
|
||||
sidecar = manifest_collection(collection)
|
||||
payload = None
|
||||
# This qdrant-client version has no `collection_exists`, and
|
||||
# `get_collection` (singular) is a known parse-bug risk in this
|
||||
# environment — list collections and check membership instead.
|
||||
existing = {col.name for col in client.get_collections().collections}
|
||||
if sidecar in existing:
|
||||
points = client.retrieve(sidecar, [MANIFEST_POINT_ID], with_payload=True)
|
||||
if points:
|
||||
payload = points[0].payload
|
||||
check_manifest(payload, collection, embedder.model_id, settings.embedding_dimensions)
|
||||
|
||||
|
||||
def build_runtime(settings: Settings):
|
||||
metrics = _build_metrics(settings)
|
||||
if settings.embedding_provider == "disabled":
|
||||
return None, None, PostgresTraceRepository(settings.postgres_dsn), metrics
|
||||
if settings.embedding_provider not in ("section-only", "local-smoke", "cohere-v4"):
|
||||
if settings.embedding_provider != "cohere-v4":
|
||||
raise ValueError(
|
||||
"No production query embedder is configured. Supported values: "
|
||||
"EMBEDDING_PROVIDER=section-only (default; section route only), "
|
||||
"local-smoke (plumbing only) or cohere-v4"
|
||||
"EMBEDDING_PROVIDER=cohere-v4 (semantic query embedding) or "
|
||||
"disabled. The old local/section-only stubs were removed."
|
||||
)
|
||||
|
||||
client = QdrantClient(
|
||||
@@ -89,19 +140,21 @@ def build_runtime(settings: Settings):
|
||||
api_key=settings.qdrant_api_key,
|
||||
timeout=30,
|
||||
)
|
||||
# A collection built with one model and queried with another returns hits
|
||||
# and raises nothing; the results are just meaningless. Keep this in step
|
||||
# with `model_id` in the collection's manifest.
|
||||
if settings.embedding_provider == "cohere-v4":
|
||||
embedder = BedrockCohereQueryEmbedder(
|
||||
settings.embedding_dimensions, region=settings.aws_region
|
||||
)
|
||||
elif settings.embedding_provider == "local-smoke":
|
||||
embedder = LocalHashQueryEmbedder(settings.embedding_dimensions)
|
||||
else:
|
||||
embedder = SectionOnlyQueryEmbedder(settings.embedding_dimensions)
|
||||
embedder = BedrockCohereQueryEmbedder(
|
||||
settings.embedding_dimensions, region=settings.aws_region
|
||||
)
|
||||
# F-05: a collection built with one model and queried with another
|
||||
# returns hits and raises nothing — the results are just meaningless,
|
||||
# with no error to notice. Refuse to start rather than search with
|
||||
# vectors this collection was not built from.
|
||||
_verify_corpus_manifest(client, settings.qdrant_collection, embedder, settings)
|
||||
section_resolver = SectionResolver()
|
||||
resolver = CatalogDrugResolver(load_aliases(settings.entities_path))
|
||||
aliases = load_aliases(settings.entities_path)
|
||||
# Kept only for deterministic input-time autocomplete (`RagAgent.complete`)
|
||||
# and to satisfy `GroundedAnswerService`'s constructor — its fuzzy
|
||||
# `.resolve()` is no longer on the live query path; `RagAgent` resolves
|
||||
# drug identity through `LlmQueryUnderstander` against the same catalog.
|
||||
resolver = CatalogDrugResolver(aliases)
|
||||
retrieval = RetrievalService(
|
||||
QdrantRetriever(client, settings.qdrant_collection, embedder),
|
||||
QdrantParentStore(client, settings.qdrant_collection),
|
||||
@@ -110,19 +163,22 @@ def build_runtime(settings: Settings):
|
||||
reranker=_build_reranker(settings),
|
||||
)
|
||||
routing = QueryRoutingService(retrieval, resolver)
|
||||
generator = _build_generator(settings)
|
||||
answers = GroundedAnswerService(
|
||||
routing, generator=_build_generator(settings), metrics=metrics or NullMetrics()
|
||||
routing, generator=generator, metrics=metrics or NullMetrics()
|
||||
)
|
||||
# The conversational layer reuses the same resolvers and the safe answer
|
||||
# engine, adding only turn understanding, follow-up inheritance and the
|
||||
# clarify/refine loop around it. InMemory store for now; a Postgres-backed
|
||||
# store is the persistence follow-up.
|
||||
conversational = ConversationalLoopService(
|
||||
trace_writer = PostgresTraceRepository(settings.postgres_dsn)
|
||||
if generator is None:
|
||||
# The new front end understands a turn with the same LLM call that
|
||||
# answers it — with no generator configured there is no query
|
||||
# understanding either, so there is no conversational/agent
|
||||
# capability to offer. Answer-only (retrieval-verified, no
|
||||
# generation) still works through `answers` directly.
|
||||
return answers, None, trace_writer, metrics
|
||||
agent = RagAgent(
|
||||
understander=LlmQueryUnderstander(generator, _catalog_names(aliases), resolver),
|
||||
retrieval=retrieval,
|
||||
answers=answers,
|
||||
resolver=resolver,
|
||||
section_resolver=section_resolver,
|
||||
store=InMemoryConversationStore(),
|
||||
summariser=DeterministicSummariser(),
|
||||
metrics=metrics or NullMetrics(),
|
||||
autocomplete=resolver,
|
||||
)
|
||||
return answers, conversational, PostgresTraceRepository(settings.postgres_dsn), metrics
|
||||
return answers, agent, trace_writer, metrics
|
||||
|
||||
Reference in New Issue
Block a user