Add production condition retrieval smoke test
This commit is contained in:
@@ -23,6 +23,10 @@ class RetrievalTrace:
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class FeedbackTraceNotFound(LookupError):
|
||||
"""The answer trace was never persisted, so feedback cannot be linked."""
|
||||
|
||||
|
||||
class PostgresTraceRepository:
|
||||
"""Opens a new connection per call — no pooling (F-09: a real pool, with
|
||||
startup-time lifecycle, is a further improvement not made here).
|
||||
@@ -102,6 +106,39 @@ class PostgresTraceRepository:
|
||||
created_at=row[10],
|
||||
)
|
||||
|
||||
def save_feedback(
|
||||
self,
|
||||
*,
|
||||
trace_id: str,
|
||||
rating: str,
|
||||
comment: str | None,
|
||||
conversation_id: str | None,
|
||||
) -> str:
|
||||
"""Create or replace one user's verdict for one persisted answer."""
|
||||
import psycopg
|
||||
|
||||
feedback_id = str(uuid.uuid4())
|
||||
with psycopg.connect(self._dsn, connect_timeout=5) as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
INSERT INTO rag_answer_feedback (
|
||||
feedback_id, trace_id, conversation_id, rating, comment
|
||||
)
|
||||
SELECT %s, trace_id, %s, %s, %s
|
||||
FROM rag_retrieval_trace WHERE trace_id = %s
|
||||
ON CONFLICT (trace_id) DO UPDATE SET
|
||||
conversation_id = EXCLUDED.conversation_id,
|
||||
rating = EXCLUDED.rating,
|
||||
comment = EXCLUDED.comment,
|
||||
updated_at = now()
|
||||
RETURNING feedback_id::text
|
||||
""",
|
||||
(feedback_id, conversation_id, rating, comment, trace_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise FeedbackTraceNotFound(trace_id)
|
||||
return str(row[0])
|
||||
|
||||
|
||||
class PostgresConversationStore:
|
||||
"""Durable, cross-worker replacement for `RagAgent`'s in-process
|
||||
|
||||
@@ -106,6 +106,10 @@ def _document(payload: dict[str, Any]) -> RetrievalDocument:
|
||||
part_index=payload.get("part_index"),
|
||||
part_count=payload.get("part_count"),
|
||||
context_labels=tuple(payload.get("context_labels") or ()),
|
||||
section_title=payload.get("section_display_name"),
|
||||
source_document=payload.get(
|
||||
"source_document", "Dược thư Quốc gia Việt Nam 2018"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -193,7 +197,13 @@ class QdrantRetriever:
|
||||
return [hit for _, hit in hits]
|
||||
|
||||
|
||||
def search_lexical(self, query: str, drug_id: str, limit: int) -> list[SearchHit]:
|
||||
def search_lexical(
|
||||
self,
|
||||
query: str,
|
||||
drug_id: str,
|
||||
limit: int,
|
||||
section_keys: tuple[str, ...] | None = None,
|
||||
) -> list[SearchHit]:
|
||||
"""Keyword/BM25-style candidates across ALL of one drug's sections,
|
||||
ranked by term overlap with `query`.
|
||||
|
||||
@@ -216,7 +226,13 @@ class QdrantRetriever:
|
||||
distinct matched tokens, a transparent stand-in for a real BM25 score
|
||||
given no term-frequency/IDF statistics are computed here.
|
||||
"""
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchText, MatchValue
|
||||
from qdrant_client.models import (
|
||||
FieldCondition,
|
||||
Filter,
|
||||
MatchAny,
|
||||
MatchText,
|
||||
MatchValue,
|
||||
)
|
||||
|
||||
from rag.text import normalize_name
|
||||
|
||||
@@ -225,10 +241,15 @@ class QdrantRetriever:
|
||||
if not tokens:
|
||||
return []
|
||||
|
||||
must = [FieldCondition(key="drug_id", match=MatchValue(value=drug_id))]
|
||||
if section_keys:
|
||||
must.append(
|
||||
FieldCondition(key="section_key", match=MatchAny(any=list(section_keys)))
|
||||
)
|
||||
points, _ = self._client.scroll(
|
||||
collection_name=self._collection_name,
|
||||
scroll_filter=Filter(
|
||||
must=[FieldCondition(key="drug_id", match=MatchValue(value=drug_id))],
|
||||
must=must,
|
||||
should=[FieldCondition(key="text", match=MatchText(text=t)) for t in tokens],
|
||||
),
|
||||
limit=max(limit * 4, 20),
|
||||
@@ -305,8 +326,10 @@ class QdrantRetriever:
|
||||
risk — the same "exact match wins, no-match-means-None" philosophy
|
||||
`find_by_section` already uses, applied across drugs instead of
|
||||
within one. `limit` caps how many DRUGS are returned (one hit per
|
||||
drug, first match wins), not how many chunks are scanned — a common
|
||||
symptom can match far more drugs than is useful to show.
|
||||
drug. This adapter returns a ranked CHUNK pool; the retrieval service
|
||||
groups those hits by ``drug_id`` and applies the final entity-level
|
||||
cap. Keeping that boundary explicit prevents Qdrant scroll order or
|
||||
chunk count from becoming an accidental drug ranking.
|
||||
|
||||
Prose only: a `block_descriptor` chunk carries no real `chi_dinh`
|
||||
text (its text is built only from metadata per the quarantine
|
||||
@@ -343,7 +366,6 @@ class QdrantRetriever:
|
||||
]
|
||||
)
|
||||
hits: list[SearchHit] = []
|
||||
seen_drugs: set[str] = set()
|
||||
offset = None
|
||||
while True:
|
||||
points, offset = self._client.scroll(
|
||||
@@ -355,19 +377,21 @@ class QdrantRetriever:
|
||||
)
|
||||
for point in points:
|
||||
payload = dict(point.payload or {})
|
||||
drug_id = payload.get("drug_id")
|
||||
if drug_id in seen_drugs:
|
||||
continue
|
||||
text = normalize_name(payload.get("text", ""))
|
||||
if not needle_pattern.search(f" {text} "):
|
||||
match = needle_pattern.search(f" {text} ")
|
||||
if not match:
|
||||
continue
|
||||
seen_drugs.add(drug_id)
|
||||
hits.append(SearchHit(_document(payload), 1.0))
|
||||
if len(hits) >= limit:
|
||||
return hits
|
||||
# Relevance of one chunk, not popularity of its drug: prefer
|
||||
# a direct phrase near the start of concise indication text.
|
||||
# The service later takes MAX per drug, never SUM/count.
|
||||
words = max(1, len(text.split()))
|
||||
position = max(0, len(text[: match.start()].split()))
|
||||
score = 1.0 + 1.0 / (1.0 + position) + 1.0 / (1.0 + words / 40.0)
|
||||
hits.append(SearchHit(_document(payload), score))
|
||||
if offset is None:
|
||||
break
|
||||
return hits
|
||||
hits.sort(key=lambda hit: (-hit.score, hit.document.doc_id))
|
||||
return hits[:limit]
|
||||
|
||||
def search_indication(self, query: str, limit: int) -> list[SearchHit]:
|
||||
"""Dense-vector fallback for `find_by_indication` when no exact
|
||||
@@ -399,13 +423,8 @@ class QdrantRetriever:
|
||||
with_payload=True,
|
||||
)
|
||||
hits: list[SearchHit] = []
|
||||
seen_drugs: set[str] = set()
|
||||
for point in points:
|
||||
payload = dict(point.payload or {})
|
||||
drug_id = payload.get("drug_id")
|
||||
if drug_id in seen_drugs:
|
||||
continue
|
||||
seen_drugs.add(drug_id)
|
||||
hits.append(SearchHit(_document(payload), float(point.score)))
|
||||
if len(hits) >= limit:
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user