diff --git a/src/virtualme/interview/bot.py b/src/virtualme/interview/bot.py index 0e0804e..c305ee8 100644 --- a/src/virtualme/interview/bot.py +++ b/src/virtualme/interview/bot.py @@ -440,8 +440,13 @@ async def _handle_light_greeting( last_asked = await db.get_last_assistant_content(session.id) if last_asked: is_restart_resume = _is_restart_reply(last_asked) + raw_last_asked = last_asked last_asked = _clean_resume_question(last_asked) - if is_restart_resume or _has_unresolved_placeholder(last_asked): + if ( + is_restart_resume + or _is_control_message(raw_last_asked) + or _has_unresolved_placeholder(last_asked) + ): rendered_question = await _final_reply(interviewee_id, question, active_client, db) reply = ( f"{progress_prefix}\n" @@ -477,6 +482,8 @@ def _clean_resume_question(content: str) -> str: return cleaned.split("\n", 1)[-1].strip() if cleaned.startswith("我們先回到剛才這題。"): return cleaned.split("\n", 1)[-1].strip() + if cleaned.startswith("這題如果不好說"): + return cleaned.split("\n", 1)[-1].strip() return cleaned @@ -484,6 +491,25 @@ def _is_restart_reply(content: str) -> bool: return content.strip().startswith("好, 我會從頭開始萃取。") +def _is_control_message(content: str) -> bool: + cleaned = content.strip() + exact_control_messages = { + _pause_current_question(), + "好,今天先到這裡。我會把這段先整理起來。", # noqa: RUF001 + INTERVIEW_ERROR_REPLY, + format_retalk_needs_dimension(), + format_generate_profile_denied(), + "行為模式檔草稿輸出失敗; 資料仍保留在訪談資料庫, 請稍後再試。", + } + if cleaned in exact_control_messages: + return True + return ( + _is_restart_reply(cleaned) + or cleaned.startswith("我們現在正在收集的人格維度是【") + or cleaned.startswith("行為模式檔 v0") + ) + + def _has_unresolved_placeholder(content: str) -> bool: return "{" in content or "}" in content @@ -713,11 +739,11 @@ async def _resolve_current_question( ) -> Question: base = await _current_pool_question(db, selector, session_id, week) last_asked = await db.get_last_assistant_content(session_id) - if last_asked: + if last_asked and not _is_control_message(last_asked): # The previous bot turn is what the current answer actually responds to # (a pool question OR a generated follow-up). Keep the pool question id # for triangulation; reflect the real wording for depth/anchor context. - return base.model_copy(update={"text": last_asked}) + return base.model_copy(update={"text": _clean_resume_question(last_asked)}) return base diff --git a/tests/unit/test_bot.py b/tests/unit/test_bot.py index 5c4efd2..b4aa4c1 100644 --- a/tests/unit/test_bot.py +++ b/tests/unit/test_bot.py @@ -3,7 +3,13 @@ from pydantic import SecretStr from virtualme.config import Settings -from virtualme.interview.bot import _handle_non_answer, _pause_current_question +from virtualme.interview.bot import ( + _handle_light_greeting, + _handle_non_answer, + _is_control_message, + _pause_current_question, + _resolve_current_question, +) from virtualme.storage.db import Dimension, Question, Session @@ -39,6 +45,50 @@ async def compute_coverage_gap(self, interviewee_id: str): return {} +class _GreetingDB: + def __init__(self, last_assistant_content: str): + self.last_assistant_content = last_assistant_content + self.current_question_id = None + self.saved_turns = [] + self.redactions = [] + self.recorded_questions = [] + + async def save_turn(self, session_id: int, role: str, content: str): + self.saved_turns.append((session_id, role, content)) + return SimpleNamespace(id=len(self.saved_turns)) + + async def save_redactions(self, turn_id: int, redactions: list): + self.redactions.append((turn_id, redactions)) + + async def get_current_question_id(self, session_id: int): + return self.current_question_id + + async def set_current_question_id(self, session_id: int, question_id: str): + self.current_question_id = question_id + + async def record_question_asked(self, interviewee_id: str, question_id: str, week: int): + self.recorded_questions.append((interviewee_id, question_id, week)) + + async def load_anchors_summary(self, interviewee_id: str): + return {} + + async def get_last_assistant_content(self, session_id: int) -> str | None: + return self.last_assistant_content + + async def compute_coverage_gap(self, interviewee_id: str): + return {} + + +def _selector(question: Question): + return SimpleNamespace(question_pool={question.week: [question]}) + + +def test_is_control_message_detects_control_replies(): + assert _is_control_message(_pause_current_question()) is True + assert _is_control_message("好,今天先到這裡。我會把這段先整理起來。") is True # noqa: RUF001 + assert _is_control_message("你最近工作中最卡的一件事是什麼?") is False + + async def test_handle_non_answer_first_evasion_returns_gentle_bridge(): question = Question( id="Q1", @@ -88,3 +138,41 @@ async def test_handle_non_answer_second_evasion_pauses(): ) assert reply == _pause_current_question() + + +async def test_handle_light_greeting_falls_back_when_last_assistant_turn_is_pause(): + question = Question( + id="Q1", + week=1, + dimension=Dimension.STATE, + text="請說說您最近的工作狀況。", + ) + db = _GreetingDB(last_assistant_content=_pause_current_question()) + + reply = await _handle_light_greeting( + "u1", + "哈囉", + Session(id=1, interviewee_id="u1", week=1), + _Claude(), + db, + _selector(question), + ) + + assert "剛才問的是" not in reply + assert "我們從【近況】開始。" in reply + assert "請說說您最近的工作狀況。" in reply + + +async def test_resolve_current_question_uses_pool_question_when_last_assistant_turn_is_pause(): + question = Question( + id="Q1", + week=1, + dimension=Dimension.STATE, + text="請說說您最近的工作狀況。", + ) + db = _GreetingDB(last_assistant_content=_pause_current_question()) + db.current_question_id = question.id + + resolved = await _resolve_current_question(db, _selector(question), session_id=1, week=1) + + assert resolved.text == question.text