190 lines
6.8 KiB
Python
190 lines
6.8 KiB
Python
"""Follow-ups must inherit context, and must never inherit it silently.
|
|
|
|
The cases here are the ones the owner named on 2026-08-05: "còn trẻ em thì
|
|
sao?", "giải thích kỹ hơn", and not making the user repeat themselves. The
|
|
adversarial cases are the ones that make inheritance dangerous in a formulary
|
|
— a stale drug, and an explicit mention being overridden by context.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from rag.conversation import (
|
|
FOCUS_TTL_TURNS,
|
|
ConversationState,
|
|
Focus,
|
|
Turn,
|
|
detect_population,
|
|
detect_verbosity,
|
|
looks_like_followup,
|
|
resolve_against,
|
|
update_focus,
|
|
)
|
|
|
|
|
|
def _state(turn_count: int = 1, **focus_fields) -> ConversationState:
|
|
focus = Focus()
|
|
for name, value in focus_fields.items():
|
|
focus = focus.with_field(name, value, turn_count - 1)
|
|
return ConversationState("c1", focus=focus, turn_count=turn_count)
|
|
|
|
|
|
# --- the follow-ups the owner asked for --------------------------------------
|
|
|
|
|
|
def test_con_tre_em_thi_sao_inherits_drug_and_section():
|
|
state = _state(drug_id="metformin", section_key="lieu_luong_va_cach_dung")
|
|
|
|
resolved = resolve_against(state, "còn trẻ em thì sao?", None, None)
|
|
|
|
assert resolved.drug_id == "metformin"
|
|
assert resolved.section_key == "lieu_luong_va_cach_dung"
|
|
assert resolved.population == "tre_em"
|
|
assert resolved.inherited_drug is True
|
|
|
|
|
|
def test_giai_thich_ky_hon_sets_verbosity_and_keeps_the_topic():
|
|
state = _state(drug_id="warfarin", section_key="tuong_tac_thuoc")
|
|
|
|
resolved = resolve_against(state, "giải thích kỹ hơn", None, None)
|
|
|
|
assert resolved.drug_id == "warfarin"
|
|
assert resolved.verbosity == "detailed"
|
|
|
|
|
|
def test_the_user_is_not_made_to_repeat_the_drug():
|
|
state = _state(drug_id="metformin")
|
|
|
|
resolved = resolve_against(state, "chống chỉ định", None, "chong_chi_dinh")
|
|
|
|
assert resolved.drug_id == "metformin"
|
|
assert resolved.section_key == "chong_chi_dinh"
|
|
|
|
|
|
# --- what makes inheritance safe ---------------------------------------------
|
|
|
|
|
|
def test_an_explicit_drug_always_beats_context():
|
|
"""Naming a drug must override whatever the conversation was about, or a
|
|
deliberate topic change silently answers about the previous medicine."""
|
|
state = _state(drug_id="metformin", section_key="lieu_luong_va_cach_dung")
|
|
|
|
resolved = resolve_against(state, "liều dùng warfarin", "warfarin", None)
|
|
|
|
assert resolved.drug_id == "warfarin"
|
|
assert resolved.inherited_drug is False
|
|
|
|
|
|
def test_a_stale_drug_is_dropped_rather_than_inherited():
|
|
"""Beyond the TTL the drug is not context, it is a hazard."""
|
|
state = _state(turn_count=FOCUS_TTL_TURNS + 3, drug_id="metformin")
|
|
# `_state` stamps at turn_count - 1, so age is 1; age it past the TTL.
|
|
aged = ConversationState(
|
|
"c1",
|
|
focus=Focus(drug_id="metformin", set_at_turn={"drug_id": 0}),
|
|
turn_count=FOCUS_TTL_TURNS + 2,
|
|
)
|
|
|
|
assert state.inherited("drug_id") == "metformin"
|
|
assert aged.inherited("drug_id") is None
|
|
|
|
resolved = resolve_against(aged, "còn trẻ em thì sao?", None, None)
|
|
assert resolved.drug_id is None
|
|
|
|
|
|
def test_an_inherited_drug_must_be_named_in_the_answer():
|
|
state = _state(drug_id="metformin")
|
|
|
|
inherited = resolve_against(state, "còn trẻ em thì sao?", None, None)
|
|
explicit = resolve_against(state, "liều warfarin", "warfarin", None)
|
|
|
|
assert inherited.needs_carry_over_notice is True
|
|
assert explicit.needs_carry_over_notice is False
|
|
|
|
|
|
# --- phrase detection ---------------------------------------------------------
|
|
|
|
|
|
def test_longest_population_phrase_wins():
|
|
"""`phụ nữ cho con bú` must not be read as `phụ nữ`, and `trẻ sơ sinh`
|
|
must not be read as `trẻ em` — the same rule `sections.py` relies on."""
|
|
assert detect_population("phụ nữ cho con bú") == "phu_nu_cho_con_bu"
|
|
assert detect_population("trẻ sơ sinh dùng sao") == "tre_so_sinh"
|
|
assert detect_population("bà bầu uống được không") == "phu_nu_co_thai"
|
|
assert detect_population("liều cho người lớn") == "nguoi_lon"
|
|
|
|
|
|
def test_no_population_named_is_none_not_a_guess():
|
|
assert detect_population("liều dùng paracetamol") is None
|
|
assert detect_verbosity("liều dùng paracetamol") is None
|
|
|
|
|
|
def test_followup_markers():
|
|
assert looks_like_followup("còn trẻ em thì sao?") is True
|
|
assert looks_like_followup("so với metformin thì sao") is True
|
|
assert looks_like_followup("liều dùng paracetamol") is False
|
|
|
|
|
|
# --- window and focus update --------------------------------------------------
|
|
|
|
|
|
def test_recent_window_evicts_oldest():
|
|
state = ConversationState("c1")
|
|
for index in range(8):
|
|
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
|
|
|
|
assert len(state.recent) == 6
|
|
assert state.recent[0].text == "q2"
|
|
assert state.turn_count == 8
|
|
|
|
|
|
def test_evicted_turns_reach_overflow_not_silently_dropped():
|
|
"""Bug fixed 2026-08-06 (Codex review, F-06): `overflow()` used to check
|
|
`len(self.recent) > window`, but `append()` already truncates `recent`
|
|
to `window`, so that comparison could never be true — evicted turns
|
|
never reached the summariser no matter how long a conversation ran.
|
|
Exact repro from the review: 8 turns into a window of 6."""
|
|
state = ConversationState("c1")
|
|
for index in range(8):
|
|
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
|
|
|
|
overflow = state.overflow()
|
|
|
|
assert [turn.text for turn in overflow] == ["q0", "q1"]
|
|
|
|
|
|
def test_overflow_accumulates_across_the_two_appends_one_turn_makes():
|
|
"""A live turn typically calls `append()` twice in a row (user, then
|
|
assistant). Each can evict at most one turn; the second call's overflow
|
|
must not overwrite, and so lose, the first's."""
|
|
state = ConversationState("c1")
|
|
for index in range(6):
|
|
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
|
|
assert state.overflow() == () # window exactly full, nothing evicted yet
|
|
|
|
state = state.append(Turn("user", "q6", "2026-08-05"), window=6)
|
|
state = state.append(Turn("assistant", "a6", "2026-08-05"), window=6)
|
|
|
|
assert [turn.text for turn in state.overflow()] == ["q0", "q1"]
|
|
|
|
|
|
def test_overflow_is_empty_again_after_the_caller_clears_it():
|
|
from dataclasses import replace
|
|
|
|
state = ConversationState("c1")
|
|
for index in range(8):
|
|
state = state.append(Turn("user", f"q{index}", "2026-08-05"), window=6)
|
|
assert state.overflow() != ()
|
|
|
|
state = replace(state, pending_overflow=())
|
|
|
|
assert state.overflow() == ()
|
|
|
|
|
|
def test_focus_update_stamps_the_current_turn():
|
|
state = _state(turn_count=3)
|
|
|
|
resolved = resolve_against(state, "liều dùng metformin", "metformin", "lieu_luong_va_cach_dung")
|
|
focus = update_focus(state, resolved)
|
|
|
|
assert focus.drug_id == "metformin"
|
|
assert focus.set_at_turn["drug_id"] == 3
|