Wire up query history: localStorage session persistence + sidebar UI
This commit is contained in:
@@ -1,13 +1,23 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from adapters.prometheus import PrometheusMetrics
|
||||
from adapters.postgres import FeedbackTraceNotFound
|
||||
from adapters.postgres import FeedbackTraceNotFound, RetrievalTrace
|
||||
from config import Settings
|
||||
from main import create_app
|
||||
from rag.agent import AgentReply
|
||||
from rag.answer import DISCLAIMER, Citation, GroundedAnswerService
|
||||
from rag.metrics import TRACE_WRITE_FAILED, InMemoryMetrics
|
||||
from rag.models import EvidenceDecision, QueryIntent, RetrievalResult, SubjectScope
|
||||
from rag.models import (
|
||||
EvidenceDecision,
|
||||
QueryIntent,
|
||||
RetrievalDocument,
|
||||
RetrievalResult,
|
||||
SearchHit,
|
||||
SourceRef,
|
||||
SubjectScope,
|
||||
)
|
||||
|
||||
|
||||
class FixedRouting:
|
||||
@@ -31,6 +41,9 @@ class MemoryTraceWriter:
|
||||
self.feedback.append(fields)
|
||||
return "feedback-1"
|
||||
|
||||
def list_by_conversation(self, conversation_id, limit):
|
||||
return []
|
||||
|
||||
|
||||
def test_feedback_is_linked_to_the_answer_trace():
|
||||
traces = MemoryTraceWriter()
|
||||
@@ -67,6 +80,76 @@ def test_feedback_rejects_an_unpersisted_trace():
|
||||
assert response.json() == {"detail": "trace_not_found"}
|
||||
|
||||
|
||||
class FakeHistoryTraceWriter(MemoryTraceWriter):
|
||||
def __init__(self, by_conversation):
|
||||
super().__init__()
|
||||
self._by_conversation = by_conversation
|
||||
self.calls = []
|
||||
|
||||
def list_by_conversation(self, conversation_id, limit):
|
||||
self.calls.append((conversation_id, limit))
|
||||
return self._by_conversation.get(conversation_id, [])
|
||||
|
||||
|
||||
def test_history_lists_past_queries_for_a_conversation_most_recent_first():
|
||||
when = datetime(2026, 8, 14, 10, 0, tzinfo=timezone.utc)
|
||||
traces = FakeHistoryTraceWriter({
|
||||
"case-1": [
|
||||
RetrievalTrace(
|
||||
trace_id="t2", query="Chống chỉ định metformin?",
|
||||
subject_scope="human", intent="fact_lookup",
|
||||
decision="answerable", reason="grounded_evidence_available",
|
||||
resolved_drug_id="metformin", citations=(),
|
||||
conversation_id="case-1", created_at=when,
|
||||
),
|
||||
RetrievalTrace(
|
||||
trace_id="t1", query="Chỉ định metformin?",
|
||||
subject_scope="human", intent="fact_lookup",
|
||||
decision="answerable", reason="grounded_evidence_available",
|
||||
resolved_drug_id="metformin", citations=(),
|
||||
conversation_id="case-1", created_at=when,
|
||||
),
|
||||
],
|
||||
})
|
||||
app = create_app(settings=Settings(), trace_writer=traces)
|
||||
|
||||
response = TestClient(app).get("/v1/rag/history", params={"conversation_id": "case-1"})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert [item["query"] for item in body["items"]] == [
|
||||
"Chống chỉ định metformin?", "Chỉ định metformin?",
|
||||
]
|
||||
assert body["items"][0]["trace_id"] == "t2"
|
||||
assert body["items"][0]["decision"] == "answerable"
|
||||
assert traces.calls == [("case-1", 50)]
|
||||
|
||||
|
||||
def test_history_with_empty_conversation_id_returns_no_rows_and_does_not_query():
|
||||
"""An empty/missing id must not silently fall through to an unscoped
|
||||
listing — there is no auth anywhere in this system to make that safe."""
|
||||
traces = FakeHistoryTraceWriter({})
|
||||
app = create_app(settings=Settings(), trace_writer=traces)
|
||||
|
||||
response = TestClient(app).get("/v1/rag/history", params={"conversation_id": " "})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"items": []}
|
||||
assert traces.calls == []
|
||||
|
||||
|
||||
def test_history_for_unknown_conversation_is_empty_not_an_error():
|
||||
traces = FakeHistoryTraceWriter({})
|
||||
app = create_app(settings=Settings(), trace_writer=traces)
|
||||
|
||||
response = TestClient(app).get(
|
||||
"/v1/rag/history", params={"conversation_id": "never-seen"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"items": []}
|
||||
|
||||
|
||||
def test_health_and_fail_closed_rag_response_are_traced():
|
||||
traces = MemoryTraceWriter()
|
||||
app = create_app(
|
||||
@@ -248,6 +331,137 @@ def test_suggest_with_no_agent_configured_returns_empty():
|
||||
assert response.json() == {"suggestions": []}
|
||||
|
||||
|
||||
class FakeSectionRetriever:
|
||||
def __init__(self, sections=None, section_texts=None):
|
||||
self._sections = sections or {}
|
||||
self._section_texts = section_texts or {}
|
||||
self.calls = []
|
||||
|
||||
def list_sections(self, drug_id):
|
||||
self.calls.append(drug_id)
|
||||
return self._sections.get(drug_id, [])
|
||||
|
||||
def find_by_section(self, drug_id, section_key):
|
||||
self.calls.append((drug_id, section_key))
|
||||
return self._section_texts.get((drug_id, section_key), [])
|
||||
|
||||
|
||||
def test_list_sections_returns_the_real_per_drug_checklist():
|
||||
retriever = FakeSectionRetriever({
|
||||
"metformin": [
|
||||
("chi_dinh", "Chỉ định"),
|
||||
("chong_chi_dinh", "Chống chỉ định"),
|
||||
],
|
||||
})
|
||||
app = create_app(
|
||||
settings=Settings(), trace_writer=MemoryTraceWriter(), section_retriever=retriever
|
||||
)
|
||||
response = TestClient(app).get("/v1/rag/sections", params={"drug_id": "metformin"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"sections": [
|
||||
{"section_key": "chi_dinh", "section_title": "Chỉ định"},
|
||||
{"section_key": "chong_chi_dinh", "section_title": "Chống chỉ định"},
|
||||
]
|
||||
}
|
||||
assert retriever.calls == ["metformin"]
|
||||
|
||||
|
||||
def test_list_sections_with_no_retriever_configured_is_503_not_an_empty_list():
|
||||
"""Distinct from `/suggest`'s empty-list fallback on purpose: an empty
|
||||
list here would read as "this drug has zero sections", which is false —
|
||||
it means the backend isn't configured at all."""
|
||||
app = create_app(settings=Settings(), trace_writer=MemoryTraceWriter())
|
||||
response = TestClient(app).get("/v1/rag/sections", params={"drug_id": "metformin"})
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def _hit(text, *, part_index=0, printed_page=200, physical_page=195, quarantined=False):
|
||||
return SearchHit(
|
||||
document=RetrievalDocument(
|
||||
doc_id=f"metformin__chong_chi_dinh__{part_index}",
|
||||
drug_id="metformin",
|
||||
kind="block_descriptor" if quarantined else "prose",
|
||||
text=text,
|
||||
section_key="chong_chi_dinh",
|
||||
section_title="Chống chỉ định",
|
||||
source_refs=(SourceRef(
|
||||
physical_page=physical_page, precision="exact", printed_page=printed_page,
|
||||
),),
|
||||
part_index=part_index,
|
||||
requires_visual_check=quarantined,
|
||||
),
|
||||
score=1.0,
|
||||
)
|
||||
|
||||
|
||||
def test_section_text_joins_parts_in_order_with_page_provenance():
|
||||
retriever = FakeSectionRetriever(section_texts={
|
||||
("metformin", "chong_chi_dinh"): [
|
||||
_hit("Phần một.", part_index=0, printed_page=200),
|
||||
_hit("Phần hai.", part_index=1, printed_page=201),
|
||||
],
|
||||
})
|
||||
app = create_app(
|
||||
settings=Settings(), trace_writer=MemoryTraceWriter(), section_retriever=retriever
|
||||
)
|
||||
response = TestClient(app).get(
|
||||
"/v1/rag/section-text", params={"drug_id": "metformin", "section_key": "chong_chi_dinh"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["section_title"] == "Chống chỉ định"
|
||||
assert [p["text"] for p in body["parts"]] == ["Phần một.", "Phần hai."]
|
||||
assert body["parts"][0]["printed_page_start"] == 200
|
||||
assert body["parts"][0]["is_quarantined"] is False
|
||||
assert retriever.calls == [("metformin", "chong_chi_dinh")]
|
||||
|
||||
|
||||
def test_section_text_flags_quarantined_parts_instead_of_treating_them_as_verbatim():
|
||||
retriever = FakeSectionRetriever(section_texts={
|
||||
("metformin", "chong_chi_dinh"): [
|
||||
_hit(
|
||||
"METFORMIN — Chống chỉ định — bảng, trang 200. Nội dung chỉ "
|
||||
"tra cứu được trên ảnh trang gốc.",
|
||||
quarantined=True,
|
||||
),
|
||||
],
|
||||
})
|
||||
app = create_app(
|
||||
settings=Settings(), trace_writer=MemoryTraceWriter(), section_retriever=retriever
|
||||
)
|
||||
response = TestClient(app).get(
|
||||
"/v1/rag/section-text", params={"drug_id": "metformin", "section_key": "chong_chi_dinh"}
|
||||
)
|
||||
|
||||
assert response.json()["parts"][0]["is_quarantined"] is True
|
||||
|
||||
|
||||
def test_section_text_with_unknown_drug_returns_empty_parts_not_an_error():
|
||||
retriever = FakeSectionRetriever()
|
||||
app = create_app(
|
||||
settings=Settings(), trace_writer=MemoryTraceWriter(), section_retriever=retriever
|
||||
)
|
||||
response = TestClient(app).get(
|
||||
"/v1/rag/section-text", params={"drug_id": "khong_ton_tai", "section_key": "chi_dinh"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["parts"] == []
|
||||
|
||||
|
||||
def test_section_text_with_no_retriever_configured_is_503():
|
||||
app = create_app(settings=Settings(), trace_writer=MemoryTraceWriter())
|
||||
response = TestClient(app).get(
|
||||
"/v1/rag/section-text", params={"drug_id": "metformin", "section_key": "chi_dinh"}
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
# --- F-09: trace persistence is fail-open ------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user