from __future__ import annotations import json import os import uuid from contextlib import suppress from functools import lru_cache from pathlib import Path import pytest pytestmark = pytest.mark.skipif( os.getenv("RUN_INTEGRATION") != "1", reason="set RUN_INTEGRATION=1 with local PostgreSQL and Qdrant running", ) ROOT = Path(__file__).resolve().parents[3] CHUNKS = ROOT / "ingestion/data/processed/chunks.jsonl" PDF = ROOT / "ingestion/data/raw/duoc-thu-quoc-gia-viet-nam-2018.pdf" MIGRATION = Path(__file__).resolve().parents[1] / "migrations/001_rag_retrieval_trace.sql" CONVERSATION_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations/002_rag_conversation_turn.sql" ) FEEDBACK_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations/004_rag_answer_feedback.sql" ) class _PlumbingEmbedder: """Deterministic local vectors for the Qdrant round-trip plumbing tests. Not a semantic model — it exists only so an integration test can upsert and query real chunks without a cloud call. Production has exactly one query embedder (`BedrockCohereQueryEmbedder`); the old local/section-only stubs were removed, so this lives with the test that needs it. """ def __init__(self, dimensions: int) -> None: self._dimensions = dimensions @property def dimensions(self) -> int: return self._dimensions def embed_query(self, text: str) -> list[float]: import hashlib import math vector = [0.0] * self._dimensions for token in text.casefold().split(): digest = hashlib.sha256(token.encode("utf-8")).digest() index = int.from_bytes(digest[:4], "big") % self._dimensions vector[index] += 1.0 if digest[4] & 1 else -1.0 norm = math.sqrt(sum(value * value for value in vector)) return [value / norm for value in vector] if norm else vector @lru_cache def _first_real_chunk() -> dict: with CHUNKS.open(encoding="utf-8") as handle: record = json.loads(next(handle)) import fitz from ingestion.extract.page_map import build_page_map with fitz.open(PDF) as document: page_map = build_page_map(document) physical_start, physical_end = record["source_page_range"] printed_start = page_map[physical_start] printed_end = page_map[physical_end] assert printed_start is not None and printed_end is not None record["printed_page_range"] = [printed_start, printed_end] return record def test_real_qdrant_round_trip_uses_real_chunk_and_printed_folio(): from qdrant_client import QdrantClient from qdrant_client.models import Distance, PointStruct, VectorParams from adapters.qdrant import QdrantRetriever client = QdrantClient(url="http://localhost:6333") collection = f"integration_{uuid.uuid4().hex}" embedder = _PlumbingEmbedder(32) record = _first_real_chunk() try: client.create_collection( collection_name=collection, vectors_config=VectorParams(size=32, distance=Distance.COSINE), ) client.upsert( collection_name=collection, points=[PointStruct( id=str(uuid.uuid4()), vector=embedder.embed_query(record["text"]), payload=record, )], wait=True, ) hits = QdrantRetriever(client, collection, embedder).search( record["text"], record["drug_id"], 3, ) assert [hit.document.doc_id for hit in hits] == [record["chunk_id"]] assert hits[0].document.text == record["text"] assert hits[0].document.source_refs[0].printed_page_range == tuple( record["printed_page_range"] ) finally: with suppress(Exception): client.delete_collection(collection) def test_real_postgres_migration_insert_and_read_back(): from adapters.postgres import PostgresTraceRepository repository = PostgresTraceRepository( "postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu" ) repository.migrate(MIGRATION) trace_id = repository.save( query="Liều abacavir?", subject_scope="human", intent="fact_lookup", decision="answerable", reason="grounded_evidence_available", resolved_drug_id="abacavir", citations=({ "chunk_id": "abacavir__ten_chung_quoc_te__0", "printed_page_start": 101, "printed_page_end": 103, },), ) stored = repository.get(trace_id) assert stored is not None assert stored.query == "Liều abacavir?" assert stored.resolved_drug_id == "abacavir" assert stored.citations[0]["printed_page_start"] == 101 def test_real_postgres_feedback_upserts_against_a_persisted_trace(): from adapters.postgres import PostgresTraceRepository repository = PostgresTraceRepository( "postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu" ) repository.migrate(MIGRATION) repository.migrate(FEEDBACK_MIGRATION) trace_id = repository.save( query="Gút dùng thuốc gì?", subject_scope="human", intent="fact_lookup", decision="answerable", reason="grounded_evidence_available", resolved_drug_id="colchicin", citations=(), ) first = repository.save_feedback( trace_id=trace_id, rating="helpful", comment=None, conversation_id="feedback-integration", ) second = repository.save_feedback( trace_id=trace_id, rating="not_helpful", comment="Thiếu cảnh báo suy thận.", conversation_id="feedback-integration", ) assert second == first def test_real_postgres_conversation_store_round_trip(): """F-08's durable conversation history against a real Postgres, not a fake — proves `append`/`recent` actually persist and window correctly, the concrete capability this whole feature exists to add over the in-process dict it replaces.""" from adapters.postgres import PostgresConversationStore store = PostgresConversationStore( "postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu" ) store.migrate(CONVERSATION_MIGRATION) conversation_id = f"test-{uuid.uuid4()}" store.append(conversation_id, "Người dùng: liều paracetamol") store.append(conversation_id, "Trợ lý: cần biết đối tượng") store.append(conversation_id, "Người dùng: người lớn") assert store.recent(conversation_id, limit=10) == [ "Người dùng: liều paracetamol", "Trợ lý: cần biết đối tượng", "Người dùng: người lớn", ] # Windowing at read time: the oldest line falls outside a limit=2 read. assert store.recent(conversation_id, limit=2) == [ "Trợ lý: cần biết đối tượng", "Người dùng: người lớn", ] # A conversation_id that was never written to reads back empty, not an # error — the same "no history yet" case a fresh conversation hits live. assert store.recent(f"never-seen-{uuid.uuid4()}", limit=10) == [] class _FakeJsonLlm: """Deterministic stand-in for the Bedrock Converse generator. Satisfies both `JsonLlm` (query understanding) and `AnswerGenerator` (answer + entailment) — both ports are just `generate(system, user, schema) -> str` — and tells the three call shapes apart the same way `tests/test_grounded_generation.py`'s fake does: by schema shape. Deliberately not a real Bedrock call: this suite asserts exact drug id / section / citation / grounding outcomes, and this session's own live probing (`docs/progress-log.md`, F-01/F-03 entries) found real generation and entailment calls genuinely non-deterministic — the wrong foundation for a regression assertion. The wiring under test — real `RagAgent`, real `RetrievalService`/`QdrantRetriever` against a real (temporary) Qdrant collection, real `GroundedAnswerService` — is identical to production; only the cloud model call is faked. """ def __init__(self, frame_payload: dict, answer_payload: dict) -> None: self._frame_payload = frame_payload self._answer_payload = answer_payload def generate(self, system: str, user: str, schema: dict) -> str: if "turn_type" in schema: # FRAME_SCHEMA: flat, not JSON-Schema-shaped return json.dumps(self._frame_payload, ensure_ascii=False) if "entailed" in schema.get("properties", {}): return json.dumps({"entailed": True, "unsupported": []}) return json.dumps(self._answer_payload, ensure_ascii=False) def test_real_rag_agent_end_to_end_through_the_http_api(): """F-10: the *production* orchestrator (`RagAgent`), driven through the real `/v1/rag/query` HTTP endpoint against a real (temporary) Qdrant collection seeded with one real corpus chunk, with a real Postgres trace persisted and read back. Reproduces Codex's exact 2026-08-06 finding — "no current test imports RagAgent, LlmQueryUnderstander, QueryFrame, or retrieve_framed" and "the evaluation runner constructs an in-memory lexical retriever and the old resolver rather than executing the same dependency graph as the live HTTP service" — both false as of this test. """ from fastapi.testclient import TestClient from qdrant_client import QdrantClient from qdrant_client.models import Distance, PointStruct, VectorParams from adapters.postgres import PostgresTraceRepository from adapters.qdrant import QdrantParentStore, QdrantRetriever from config import Settings from main import create_app from rag.agent import RagAgent from rag.answer import GroundedAnswerService from rag.routing import CatalogDrugResolver, QueryRoutingService from rag.service import EvidencePolicy, RetrievalService from rag.understanding import LlmQueryUnderstander qdrant = QdrantClient(url="http://localhost:6333") collection = f"integration_{uuid.uuid4().hex}" embedder = _PlumbingEmbedder(32) record = dict(_first_real_chunk()) traces = PostgresTraceRepository( "postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu" ) traces.migrate(MIGRATION) try: qdrant.create_collection( collection_name=collection, vectors_config=VectorParams(size=32, distance=Distance.COSINE), ) qdrant.upsert( collection_name=collection, points=[PointStruct( id=str(uuid.uuid4()), vector=embedder.embed_query(record["text"]), payload=record, )], wait=True, ) retrieval = RetrievalService( QdrantRetriever(qdrant, collection, embedder), QdrantParentStore(qdrant, collection), EvidencePolicy(minimum_score=0.01), ) # A minimal one-drug catalog, real drug_id — F-04's candidate # bounding runs for real here (`RagAgent`/`LlmQueryUnderstander` # are not mocked), so the query must literally name the drug for # the deterministic resolver to find it as a candidate. resolver = CatalogDrugResolver({record["drug_id"]: {record["drug_name"]}}) llm = _FakeJsonLlm( frame_payload={ # A bare drug name is an overview lookup in this plumbing # test. `drug_attribute` with attribute=None now correctly # asks which monograph section the user wants, so using that # internally-inconsistent frame would only exercise the # clarification guard instead of the real retrieval path. "turn_type": "drug_overview", "drugs": [record["drug_id"]], "unknown_drugs": [], "attribute": None, "population": None, "weight_kg": None, "age_text": None, "indication": None, "needs_clarify": False, "clarify_reason": None, }, answer_payload={ "claims": [{"text": record["text"], "citations": [1]}], "evidence_sufficient": True, }, ) understander = LlmQueryUnderstander( llm, {record["drug_id"]: record["drug_name"]}, resolver, ) answers = GroundedAnswerService( QueryRoutingService(retrieval, resolver), generator=llm, ) agent = RagAgent(understander, retrieval, answers) app = create_app( settings=Settings(), answer_service=answers, conversational=agent, trace_writer=traces, ) response = TestClient(app).post("/v1/rag/query", json={ "query": record["drug_name"], "subject_scope": "human", "intent": "fact_lookup", }) assert response.status_code == 200 body = response.json() assert body["decision"] == "answerable", body assert body["resolved_drug_id"] == record["drug_id"] assert body["citations"][0]["chunk_id"] == record["chunk_id"] assert body["citations"][0]["printed_page_start"] == ( record["printed_page_range"][0] ) assert record["text"] in body["answer"] stored = traces.get(body["trace_id"]) assert stored is not None assert stored.decision == "answerable" assert stored.resolved_drug_id == record["drug_id"] assert stored.citations[0]["chunk_id"] == record["chunk_id"] finally: with suppress(Exception): qdrant.delete_collection(collection) def test_api_round_trip_uses_qdrant_and_persists_postgres_trace(): from fastapi.testclient import TestClient from qdrant_client import QdrantClient from qdrant_client.models import Distance, PointStruct, VectorParams from adapters.postgres import PostgresTraceRepository from adapters.qdrant import QdrantParentStore, QdrantRetriever from config import Settings from main import create_app from rag.answer import GroundedAnswerService from rag.routing import CatalogDrugResolver, QueryRoutingService from rag.service import EvidencePolicy, RetrievalService qdrant = QdrantClient(url="http://localhost:6333") collection = f"integration_{uuid.uuid4().hex}" embedder = _PlumbingEmbedder(32) record = dict(_first_real_chunk()) traces = PostgresTraceRepository( "postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu" ) traces.migrate(MIGRATION) try: qdrant.create_collection( collection_name=collection, vectors_config=VectorParams(size=32, distance=Distance.COSINE), ) qdrant.upsert( collection_name=collection, points=[PointStruct( id=str(uuid.uuid4()), vector=embedder.embed_query(record["text"]), payload=record, )], wait=True, ) retrieval = RetrievalService( QdrantRetriever(qdrant, collection, embedder), QdrantParentStore(qdrant, collection), EvidencePolicy(minimum_score=0.01), ) answers = GroundedAnswerService(QueryRoutingService( retrieval, CatalogDrugResolver({record["drug_id"]: {record["drug_name"]}}), )) app = create_app( settings=Settings(), answer_service=answers, trace_writer=traces, ) response = TestClient(app).post("/v1/rag/query", json={ "query": record["text"], "subject_scope": "human", "intent": "fact_lookup", }) assert response.status_code == 200 body = response.json() assert body["decision"] == "answerable" assert body["citations"][0]["chunk_id"] == record["chunk_id"] assert body["citations"][0]["printed_page_start"] == ( record["printed_page_range"][0] ) stored = traces.get(body["trace_id"]) assert stored is not None assert stored.decision == "answerable" assert stored.citations[0]["chunk_id"] == record["chunk_id"] finally: with suppress(Exception): qdrant.delete_collection(collection)