Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions src/virtualme/interview/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -477,13 +482,34 @@ 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


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

Expand Down Expand Up @@ -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


Expand Down
90 changes: 89 additions & 1 deletion tests/unit/test_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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