from typing import Any import pytest from app.service.goal_conversation_service import ( MAX_CLARIFICATION_ROUNDS, REQUIRED_FIELDS, GoalConversationService, extract_goal_entities, ) def test_extracts_explicit_goal_entities() -> None: result = extract_goal_entities( "我希望年化收益6%-10%,最大回撤15%,7天内能用钱,期限3年,基准沪深300指数" ) assert result == { "annualized_return_lower_pct": 6.0, "annualized_return_upper_pct": 10.0, "max_drawdown_pct": 15.0, "liquidity_requirement": "within_7_days", "investment_horizon_months": 36, "benchmark_name": "沪深300指数", } def test_collection_detection_does_not_intercept_goal_read() -> None: assert GoalConversationService.should_collect("我想制定投资目标") assert GoalConversationService.should_collect("收益目标是8%") assert not GoalConversationService.should_collect("查看我的投资目标") class _AsyncContext: async def __aenter__(self) -> None: return None async def __aexit__(self, *_args: object) -> bool: return False class _SessionRow: clarification_round = 0 class _Message: def __init__(self, message_id: int, content: str, trace_id: str) -> None: self.id = message_id self.content = content self.trace_id = trace_id class _FakeSession: def __init__(self, messages: list[_Message], rounds: int = 0) -> None: self.session_row = _SessionRow() self.session_row.clarification_round = rounds self.messages = messages self.added: list[Any] = [] self.scalar_calls = 0 def begin(self) -> _AsyncContext: return _AsyncContext() async def scalar(self, _statement: object) -> object: self.scalar_calls += 1 if self.scalar_calls == 1: return self.session_row if self.scalar_calls == 2: return next((item for item in self.messages if item.trace_id == "trace-2"), None) return None async def scalars(self, _statement: object) -> list[_Message]: return self.messages def add(self, item: Any) -> None: self.added.append(item) class _SessionFactory: def __init__(self, session: _FakeSession) -> None: self.session = session def __call__(self) -> "_SessionContext": return _SessionContext(self.session) class _SessionContext: def __init__(self, session: _FakeSession) -> None: self.session = session async def __aenter__(self) -> _FakeSession: return self.session async def __aexit__(self, *_args: object) -> bool: return False @pytest.mark.asyncio async def test_process_merges_turns_and_persists_only_internal_snapshot() -> None: session = _FakeSession([ _Message(1, "我想做投资目标,收益6%-10%,最大回撤15%", "trace-1"), _Message(2, "7天内用钱,期限3年,基准沪深300", "trace-2"), ]) result = await GoalConversationService(_SessionFactory(session)).process( session_id="session-1", customer_id=9000001, trace_id="trace-2", message=session.messages[-1].content, ) assert result["status"] == "complete" assert result["missing"] == [] assert session.session_row.clarification_round == 0 extraction = next( item for item in session.added if item.__class__.__name__ == "AdvisorGoalConversationExtraction" ) assert extraction.missing_fields == [] assert extraction.extracted_fields["investment_horizon_months"] == 36 @pytest.mark.asyncio async def test_process_asks_only_for_missing_fields_and_honors_limit() -> None: session = _FakeSession([_Message(3, "我希望年化收益6%-10%", "trace-2")]) result = await GoalConversationService(_SessionFactory(session)).process( session_id="session-1", customer_id=9000001, trace_id="trace-2", message="我希望年化收益6%-10%", ) assert result["status"] == "partial" assert result["missing"] == [field for field in REQUIRED_FIELDS if field not in { "annualized_return_lower_pct", "annualized_return_upper_pct" }] assert session.session_row.clarification_round == 1 assert "年化收益目标下限" not in GoalConversationService.customer_prompt(result) limited = _FakeSession(session.messages, rounds=MAX_CLARIFICATION_ROUNDS) limited_result = await GoalConversationService(_SessionFactory(limited)).process( session_id="session-1", customer_id=9000001, trace_id="trace-2", message="我还没想好" ) assert limited_result["status"] == "clarification_limit" assert "转人工顾问" in GoalConversationService.customer_prompt(limited_result)