Wire up query history: localStorage session persistence + sidebar UI

This commit is contained in:
2026-08-14 17:44:36 +07:00
parent 9be5819710
commit 057d4ed9dc
23 changed files with 1231 additions and 30 deletions
+216 -2
View File
@@ -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 ------------------------------------