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
+21
View File
@@ -99,6 +99,27 @@ def test_out_of_scope_turn_type_abstains():
assert reply.reason == "out_of_scope"
def test_out_of_scope_price_question_states_the_book_does_not_have_it():
"""Regression: found live 2026-08-14 that a price question and a
genuinely off-topic question ("thời tiết Hà Nội hôm nay?") got the exact
same vague message, which never actually says Dược thư has no pricing
data at all — it read as "maybe in an appendix not digitized yet"."""
agent = _agent(QueryFrame(turn_type="out_of_scope"))
reply = agent.handle("Paracetamol giá bao nhiêu tiền một hộp?")
assert reply.decision == "abstain"
assert reply.reason == "out_of_scope"
assert "không chứa" in reply.answer.lower()
def test_out_of_scope_offtopic_question_states_supported_scope():
agent = _agent(QueryFrame(turn_type="out_of_scope"))
reply = agent.handle("Thời tiết Hà Nội hôm nay thế nào?")
assert reply.decision == "abstain"
assert reply.reason == "out_of_scope"
assert "giá bán" not in reply.answer
assert "Dược thư" in reply.answer
def test_veterinary_phrase_abstains_even_if_the_model_missed_it():
# The model returned an ordinary-looking frame; the keyword backstop
# (`rag.policy.looks_non_human`, the same one F-02 wired server-side)
+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 ------------------------------------
@@ -227,6 +227,62 @@ def test_list_mode_skips_the_sufficiency_clarify():
assert g.generated is True
def test_list_mode_prepends_a_lookup_not_recommendation_notice_block():
"""Feature-List #14: a condition/symptom -> drug list reads like a
treatment recommendation unless it is explicitly labelled as a lookup.
The notice must be a fixed block the model never writes (so it can't be
reworded or dropped), first in `blocks`, and carry the plan's
`needs_warning` flag so the UI actually renders it set apart."""
result = _answerable(_evidence(0, 100), _evidence(1, 200))
gen = _Generator(
{"claims": [
{"text": "Đoạn bằng chứng 0", "citations": [1]},
{"text": "Đoạn bằng chứng 1", "citations": [2]},
], "evidence_sufficient": True, "clarifying_question": None},
)
service = GroundedAnswerService(_Routing(result), gen)
g = service.answer_from_result("thuốc gì trị sốt", result, list_mode=True)
assert g.blocks[0].title == "Đọc cho đúng"
assert g.blocks[0].kind == "warning"
assert "TRA CỨU" in g.blocks[0].claims[0].text
assert "KHÔNG PHẢI" in g.blocks[0].claims[0].text
assert g.plan is not None and g.plan.needs_warning is True
# The generated claims still follow, untouched, after the fixed notice.
assert len(g.blocks) == 2
def test_single_drug_answer_has_no_list_mode_notice():
"""The notice is specific to `list_mode` (multi-drug reverse lookup) —
an ordinary single-drug attribute answer must not carry it."""
evidence = Evidence(
evidence_id="a__chong_chi_dinh__0",
matched_doc_id="a__chong_chi_dinh__0",
kind="prose",
text="Chống chỉ định của thuốc A.",
score=1.0,
source_refs=(SourceRef(physical_page=100, precision="exact", printed_page=101),),
hydrated_from_parent=False,
requires_visual_check=False,
drug_id="a",
drug_name="A",
section_key="chong_chi_dinh",
)
result = _answerable(evidence)
gen = _Generator({
"claims": [{"text": "Không dùng thuốc A khi mẫn cảm.", "citations": [1]}],
"evidence_sufficient": True,
"clarifying_question": None,
"quick_replies": [],
})
service = GroundedAnswerService(_Routing(result), gen)
g = service.answer_from_result("Chống chỉ định của A?", result, list_mode=False)
assert all(block.title != "Đọc cho đúng" for block in g.blocks)
def test_list_mode_rejects_a_generated_drug_outside_candidate_set():
evidence = Evidence(
evidence_id="a__chi_dinh__0",
@@ -24,6 +24,9 @@ CONVERSATION_MIGRATION = (
FEEDBACK_MIGRATION = (
Path(__file__).resolve().parents[1] / "migrations/004_rag_answer_feedback.sql"
)
HISTORY_MIGRATION = (
Path(__file__).resolve().parents[1] / "migrations/005_rag_trace_conversation.sql"
)
class _PlumbingEmbedder:
@@ -117,6 +120,7 @@ def test_real_postgres_migration_insert_and_read_back():
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu"
)
repository.migrate(MIGRATION)
repository.migrate(HISTORY_MIGRATION)
trace_id = repository.save(
query="Liều abacavir?",
subject_scope="human",
@@ -145,6 +149,7 @@ def test_real_postgres_feedback_upserts_against_a_persisted_trace():
)
repository.migrate(MIGRATION)
repository.migrate(FEEDBACK_MIGRATION)
repository.migrate(HISTORY_MIGRATION)
trace_id = repository.save(
query="Gút dùng thuốc gì?",
subject_scope="human",
@@ -171,6 +176,43 @@ def test_real_postgres_feedback_upserts_against_a_persisted_trace():
assert second == first
def test_real_postgres_history_lists_by_conversation_most_recent_first():
"""Feature-List #25 against a real Postgres: proves the migration,
save()'s new conversation_id column, and list_by_conversation's
filter+order all actually work together, not just against fakes."""
from adapters.postgres import PostgresTraceRepository
repository = PostgresTraceRepository(
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu"
)
repository.migrate(MIGRATION)
repository.migrate(HISTORY_MIGRATION)
conversation_id = f"history-integration-{uuid.uuid4()}"
first_id = repository.save(
query="Chỉ định của metformin?", subject_scope="human", intent="fact_lookup",
decision="answerable", reason="grounded_evidence_available",
resolved_drug_id="metformin", citations=(), conversation_id=conversation_id,
)
second_id = repository.save(
query="Chống chỉ định của metformin?", subject_scope="human", intent="fact_lookup",
decision="answerable", reason="grounded_evidence_available",
resolved_drug_id="metformin", citations=(), conversation_id=conversation_id,
)
# A different conversation must never leak into this one's history.
repository.save(
query="Liều aspirin?", subject_scope="human", intent="fact_lookup",
decision="answerable", reason="grounded_evidence_available",
resolved_drug_id="aspirin", citations=(),
conversation_id=f"other-{uuid.uuid4()}",
)
rows = repository.list_by_conversation(conversation_id, limit=10)
assert [row.trace_id for row in rows] == [second_id, first_id]
assert all(row.conversation_id == conversation_id for row in rows)
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,
@@ -264,6 +306,7 @@ def test_real_rag_agent_end_to_end_through_the_http_api():
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu"
)
traces.migrate(MIGRATION)
traces.migrate(HISTORY_MIGRATION)
try:
qdrant.create_collection(
collection_name=collection,
@@ -363,6 +406,7 @@ def test_api_round_trip_uses_qdrant_and_persists_postgres_trace():
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu"
)
traces.migrate(MIGRATION)
traces.migrate(HISTORY_MIGRATION)
try:
qdrant.create_collection(
collection_name=collection,
@@ -243,3 +243,79 @@ def test_search_lexical_excludes_non_matching_sections():
hits = retriever.search_lexical("loét dạ dày", "aspirin", limit=5)
assert hits == []
def _section_meta_payload(
drug_id: str, section_key: str, display_name: str, part_index: int = 0
) -> dict:
return {
"chunk_id": f"{drug_id}__{section_key}__{part_index}", "drug_id": drug_id,
"section_key": section_key, "section_display_name": display_name,
"part_index": part_index, "chunk_kind": "prose", "text": "nội dung",
}
def test_list_sections_returns_book_order_not_scroll_order():
client = _FakeScrollClient([
_section_meta_payload("aspirin", "qua_lieu_va_xu_tri", "Quá liều và xử trí"),
_section_meta_payload("aspirin", "chi_dinh", "Chỉ định"),
_section_meta_payload("aspirin", "ten_chung_quoc_te", "Tên chung quốc tế"),
])
retriever = QdrantRetriever(client, "duocthu_v1", embedder=None)
sections = retriever.list_sections("aspirin")
assert [key for key, _ in sections] == [
"ten_chung_quoc_te", "chi_dinh", "qua_lieu_va_xu_tri",
]
assert dict(sections)["chi_dinh"] == "Chỉ định"
def test_list_sections_dedupes_multi_part_sections():
client = _FakeScrollClient([
_section_meta_payload("aspirin", "chi_dinh", "Chỉ định", part_index=0),
_section_meta_payload("aspirin", "chi_dinh", "Chỉ định", part_index=1),
_section_meta_payload("aspirin", "chi_dinh", "Chỉ định", part_index=2),
])
retriever = QdrantRetriever(client, "duocthu_v1", embedder=None)
sections = retriever.list_sections("aspirin")
assert sections == [("chi_dinh", "Chỉ định")]
def test_list_sections_includes_quarantined_only_sections():
"""A section with no prose chunk at all (only a block_descriptor) is
still a real section of the monograph — must not be filtered out the
way `find_by_drug`'s prose-only overview deliberately is."""
client = _FakeScrollClient([
{
"chunk_id": "aspirin__lieu_luong_va_cach_dung__block__p1_t0",
"drug_id": "aspirin", "section_key": "lieu_luong_va_cach_dung",
"section_display_name": "Liều lượng và cách dùng",
"chunk_kind": "block_descriptor", "text": "bảng, trang 1.",
},
])
retriever = QdrantRetriever(client, "duocthu_v1", embedder=None)
sections = retriever.list_sections("aspirin")
assert sections == [("lieu_luong_va_cach_dung", "Liều lượng và cách dùng")]
def test_list_sections_places_ten_thuong_mai_after_generic_name():
"""`ten_thuong_mai` isn't one of the book's own 19 numbered fields
(verified against the actual PDF, printed page 39) — placed right after
the generic name as the most natural adjacency."""
client = _FakeScrollClient([
_section_meta_payload("aspirin", "chi_dinh", "Chỉ định"),
_section_meta_payload("aspirin", "ten_thuong_mai", "Tên thương mại"),
_section_meta_payload("aspirin", "ten_chung_quoc_te", "Tên chung quốc tế"),
])
retriever = QdrantRetriever(client, "duocthu_v1", embedder=None)
sections = retriever.list_sections("aspirin")
assert [key for key, _ in sections] == [
"ten_chung_quoc_te", "ten_thuong_mai", "chi_dinh",
]
@@ -177,6 +177,49 @@ class TestSectionResolver:
)
class TestSectionResolverResolveAll:
"""`resolve_all` — the fix for `abstain/incomplete_answer` reproduced
live 2026-08-14 on "Chỉ định và chống chỉ định của Aspirin là gì?":
`resolve()` silently picked one section, retrieval only fetched that
one, and the still-broad question failed the completeness check against
it. These pin that a genuine two-section question reports both, while a
substring collision (the exact case `resolve()` itself guards against)
still reports only one.
"""
def test_two_genuinely_named_sections_both_reported(self) -> None:
resolver = SectionResolver()
matches = resolver.resolve_all(
"Chỉ định và chống chỉ định của Aspirin là gì?"
)
assert {m.section_key for m in matches} == {"chi_dinh", "chong_chi_dinh"}
def test_substring_collision_is_not_double_counted(self) -> None:
""""chỉ định" is a literal substring of "chống chỉ định" — this must
stay a single match, exactly like `resolve()` already guarantees."""
resolver = SectionResolver()
matches = resolver.resolve_all("Chống chỉ định của aspirin là gì?")
assert [m.section_key for m in matches] == ["chong_chi_dinh"]
def test_three_sections_named_at_once(self) -> None:
resolver = SectionResolver()
matches = resolver.resolve_all(
"Liều dùng, chống chỉ định và tương tác thuốc của Metformin?"
)
assert {m.section_key for m in matches} == {
"lieu_luong_va_cach_dung", "chong_chi_dinh", "tuong_tac_thuoc",
}
def test_single_section_question_still_returns_one(self) -> None:
resolver = SectionResolver()
matches = resolver.resolve_all("Liều dùng metformin?")
assert [m.section_key for m in matches] == ["lieu_luong_va_cach_dung"]
def test_unrecognised_question_returns_empty(self) -> None:
assert SectionResolver().resolve_all("thuốc này giá bao nhiêu") == ()
assert SectionResolver().resolve_all("") == ()
class TestSectionRouting:
def test_named_section_bypasses_similarity_entirely(self) -> None:
retriever = SectionAwareRetriever(ALL_DOCS)
+57 -5
View File
@@ -110,6 +110,58 @@ def test_golden_named_drug_section_overrides_a_misclassified_relation_frame():
assert frame.needs_clarify is False
def test_two_sections_named_at_once_clarifies_instead_of_silently_narrowing():
"""Regression for `abstain/incomplete_answer` reproduced live 2026-08-14
on "Chỉ định và chống chỉ định của Aspirin là gì?": the turn used to
silently collapse to whichever one section `_apply_named_drug_cues`
picked (chống chỉ định, being the longer phrase), so retrieval only
fetched that section's evidence while the question handed to generation
still promised both — a real, quote-backed completeness gap the
generator could never close. Must now clarify instead."""
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "drug_attribute",
"drugs": ["paracetamol_acetaminophen"],
"unknown_drugs": [],
"attribute": "chong_chi_dinh",
"population": None,
"weight_kg": None,
"age_text": None,
"indication": None,
"needs_clarify": False,
"clarify_reason": None,
}), CATALOG, RESOLVER)
frame = understander.understand(
"Chỉ định và chống chỉ định của Paracetamol là gì?"
)
assert frame.turn_type == "drug_attribute"
assert frame.drugs == ("paracetamol_acetaminophen",)
assert frame.attribute is None
assert frame.needs_clarify is True
assert set(frame.quick_replies) == {"Chỉ định", "Chống chỉ định"}
def test_single_section_named_is_unaffected_by_the_multi_section_clarify():
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "drug_attribute",
"drugs": ["paracetamol_acetaminophen"],
"unknown_drugs": [],
"attribute": "chong_chi_dinh",
"population": None,
"weight_kg": None,
"age_text": None,
"indication": None,
"needs_clarify": False,
"clarify_reason": None,
}), CATALOG, RESOLVER)
frame = understander.understand("Chống chỉ định của Paracetamol là gì?")
assert frame.attribute == "chong_chi_dinh"
assert frame.needs_clarify is False
def test_exact_candidate_does_not_repeat_the_catalog_wide_fuzzy_scan():
resolver = _FakeResolver({"metformin": "metformin"})
understander = LlmQueryUnderstander(_FixedLlm({
@@ -244,11 +296,11 @@ def test_quick_replies_are_parsed_when_the_model_offers_them():
def test_quick_replies_are_dynamic_but_bounded_before_becoming_ui_chips():
# 19 distinct valid entries (after " Người lớn " / "người lớn" dedup
# and the non-string 12 are dropped) so the 18-item cap — one per
# 20 distinct valid entries (after " Người lớn " / "người lớn" dedup
# and the non-string 12 are dropped) so the 19-item cap — one per
# monograph section, see rag/sections.py SECTION_ORDER — still trims
# the last one, not just the old 4-item cap.
extra = [f"Lựa chọn {i}" for i in range(6, 20)]
extra = [f"Lựa chọn {i}" for i in range(6, 21)]
understander = LlmQueryUnderstander(_FixedLlm({
"turn_type": "dosing_calc", "drugs": ["paracetamol_acetaminophen"],
"unknown_drugs": [], "attribute": None, "population": None,
@@ -263,11 +315,11 @@ def test_quick_replies_are_dynamic_but_bounded_before_becoming_ui_chips():
frame = understander.understand("liều paracetamol")
assert len(frame.quick_replies) == 18
assert len(frame.quick_replies) == 19
assert frame.quick_replies[:5] == (
"Người lớn", "Trẻ em", "Phụ nữ có thai", "Người cao tuổi", "Lựa chọn thứ năm"
)
assert "Lựa chọn 19" not in frame.quick_replies
assert "Lựa chọn 20" not in frame.quick_replies
def test_string_false_does_not_turn_into_a_clarification():