feat: complete advisor conversation loop
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
"""Internal persistence model for conversational investment-goal extraction."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, BigInteger, DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.model.base import Base
|
||||
|
||||
|
||||
class AdvisorGoalConversationExtraction(Base):
|
||||
"""Append-only extraction snapshot; never exposed in the customer API."""
|
||||
|
||||
__tablename__ = "advisor_goal_conversation_extraction"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
message_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False)
|
||||
session_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
extraction_version: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
extracted_fields: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
missing_fields: Mapped[list[str]] = mapped_column(JSON, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
@@ -4,6 +4,7 @@ from typing import Any
|
||||
|
||||
from app.core.contracts import AgentDefinition, AgentRequest, CoreResult, RequestContext
|
||||
from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent
|
||||
from app.service.goal_conversation_service import GoalConversationService
|
||||
|
||||
|
||||
class AdvisorAgent(FundQueryDemoAgent):
|
||||
@@ -27,6 +28,7 @@ class AdvisorAgent(FundQueryDemoAgent):
|
||||
"portfolio_analysis",
|
||||
"asset_allocation",
|
||||
"product_recommend",
|
||||
"comparison",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -35,6 +37,12 @@ class AdvisorAgent(FundQueryDemoAgent):
|
||||
self._classified_intent is not None
|
||||
and self._classified_intent.intent == "investment_goal"
|
||||
):
|
||||
if GoalConversationService.should_collect(request.message):
|
||||
goal_result = await GoalConversationService().process(
|
||||
session_id=request.session_id, customer_id=int(context.user_id),
|
||||
trace_id=context.trace_id, message=request.message,
|
||||
)
|
||||
return CoreResult(text=GoalConversationService.customer_prompt(goal_result))
|
||||
output = await self.call_tool(
|
||||
"query_investment_goal", {}, intent="investment_goal", context=context
|
||||
)
|
||||
@@ -65,6 +73,11 @@ class AdvisorAgent(FundQueryDemoAgent):
|
||||
"recommend_products", {"limit": 3}, intent="product_recommend", context=context
|
||||
)
|
||||
return CoreResult(text=self._describe_recommendation(output))
|
||||
if (
|
||||
self._classified_intent is not None
|
||||
and self._classified_intent.intent == "comparison"
|
||||
):
|
||||
return CoreResult(text="对比分析需要明确两个或多个场内基金产品,请提供基金代码或名称。")
|
||||
return await super().handle(request, context)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.core.errors import RunLeaseLostError
|
||||
from app.model.audit import InteractionAudit
|
||||
from app.model.conversation import ConversationMessage
|
||||
from app.model.platform import AgentRun, DomainEventOutbox, RequestIdempotency
|
||||
from app.model.session import ConversationSession
|
||||
|
||||
|
||||
class AgentPersistenceService:
|
||||
@@ -58,6 +59,13 @@ class AgentPersistenceService:
|
||||
run.completed_at = now
|
||||
run.updated_at = now
|
||||
run.locked_until = None
|
||||
session_row = await self.session.scalar(select(ConversationSession).where(
|
||||
ConversationSession.session_id == run.session_id,
|
||||
ConversationSession.user_id == run.user_id,
|
||||
).with_for_update())
|
||||
if session_row is not None and result.result.intent is not None:
|
||||
session_row.last_intent = result.result.intent.intent
|
||||
session_row.updated_at = now
|
||||
self.session.add(InteractionAudit(
|
||||
actor_type="agent", actor_id=run.user_id, target_customer_id=run.user_id,
|
||||
session_id=run.session_id, portal="agent", action_type="agent.run_completed",
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Conversation-level investment-goal entity extraction and gap handling."""
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.infrastructure.db import SessionFactory
|
||||
from app.model.audit import InteractionAudit
|
||||
from app.model.conversation import ConversationMessage
|
||||
from app.model.goal_conversation import AdvisorGoalConversationExtraction
|
||||
from app.model.session import ConversationSession
|
||||
|
||||
EXTRACTION_VERSION = "investment-goal-v1"
|
||||
MAX_CLARIFICATION_ROUNDS = 10
|
||||
REQUIRED_FIELDS = (
|
||||
"annualized_return_lower_pct",
|
||||
"annualized_return_upper_pct",
|
||||
"max_drawdown_pct",
|
||||
"liquidity_requirement",
|
||||
"investment_horizon_months",
|
||||
"benchmark_name",
|
||||
)
|
||||
|
||||
FIELD_LABELS = {
|
||||
"annualized_return_lower_pct": "年化收益目标下限",
|
||||
"annualized_return_upper_pct": "年化收益目标上限",
|
||||
"max_drawdown_pct": "最大回撤容忍度",
|
||||
"liquidity_requirement": "资金流动性要求",
|
||||
"investment_horizon_months": "投资期限",
|
||||
"benchmark_name": "业绩比较基准",
|
||||
}
|
||||
|
||||
_NUMBER = r"\d+(?:\.\d+)?"
|
||||
|
||||
|
||||
class SessionFactoryLike(Protocol):
|
||||
def __call__(self) -> AsyncSession: ...
|
||||
|
||||
|
||||
def _number(value: str) -> float:
|
||||
return float(Decimal(value))
|
||||
|
||||
|
||||
def extract_goal_entities(text: str) -> dict[str, Any]:
|
||||
"""Extract explicit goal facts from one user message.
|
||||
|
||||
This parser intentionally accepts only explicit financial statements. It does not
|
||||
infer a risk level, expected return, or benchmark from vague language.
|
||||
"""
|
||||
result: dict[str, Any] = {}
|
||||
return_match = re.search(
|
||||
rf"(?:年化)?(?:收益目标|收益率|收益)\s*(?:为|在|约为|目标为)?\s*({_NUMBER})\s*%?\s*"
|
||||
rf"(?:至|到|[-~~])\s*({_NUMBER})\s*%?",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if return_match:
|
||||
lower, upper = map(_number, return_match.groups())
|
||||
result["annualized_return_lower_pct"] = min(lower, upper)
|
||||
result["annualized_return_upper_pct"] = max(lower, upper)
|
||||
else:
|
||||
single_return = re.search(
|
||||
rf"(?:年化)?(?:收益目标|收益率|收益)\s*"
|
||||
rf"(?:是|为|约为|目标为)?\s*({_NUMBER})\s*%",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if single_return:
|
||||
value = _number(single_return.group(1))
|
||||
result["annualized_return_lower_pct"] = value
|
||||
result["annualized_return_upper_pct"] = value
|
||||
|
||||
drawdown = re.search(rf"(?:最大)?回撤\s*(?:容忍|控制|不超过|为|约为)?\s*({_NUMBER})\s*%?", text)
|
||||
if drawdown:
|
||||
result["max_drawdown_pct"] = _number(drawdown.group(1))
|
||||
|
||||
if re.search(r"随时|每天|每日|当天|日内", text):
|
||||
result["liquidity_requirement"] = "daily"
|
||||
else:
|
||||
days = re.search(
|
||||
r"(?:流动性|用钱|变现|取出).{0,12}?"
|
||||
r"([一两两三四五六七八九十0-9]+)\s*天",
|
||||
text,
|
||||
)
|
||||
if days is None:
|
||||
days = re.search(
|
||||
r"([一两三四五六七八九十0-9]+)\s*天(?:内|可用|能用)", text
|
||||
)
|
||||
if days:
|
||||
count = _chinese_number(days.group(1))
|
||||
if count <= 7:
|
||||
result["liquidity_requirement"] = "within_7_days"
|
||||
elif count <= 30:
|
||||
result["liquidity_requirement"] = "within_30_days"
|
||||
else:
|
||||
result["liquidity_requirement"] = "over_30_days"
|
||||
|
||||
months = re.search(rf"({_NUMBER})\s*(?:年|岁)", text)
|
||||
if months:
|
||||
result["investment_horizon_months"] = int(_number(months.group(1)) * 12)
|
||||
else:
|
||||
months = re.search(rf"({_NUMBER})\s*个?月", text)
|
||||
if months:
|
||||
result["investment_horizon_months"] = int(_number(months.group(1)))
|
||||
|
||||
benchmark = re.search(
|
||||
r"(?:业绩比较基准|比较基准|基准)\s*(?:是|为|选)?\s*"
|
||||
r"([\u4e00-\u9fffA-Za-z0-9_-]{2,32})",
|
||||
text,
|
||||
)
|
||||
if benchmark:
|
||||
result["benchmark_name"] = benchmark.group(1).strip(",。;、")
|
||||
else:
|
||||
for candidate in ("沪深300指数", "沪深300", "中证500", "中证全指", "上证指数"):
|
||||
if candidate in text:
|
||||
result["benchmark_name"] = candidate
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
def _chinese_number(value: str) -> int:
|
||||
if value.isdigit():
|
||||
return int(value)
|
||||
digits = {"一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5,
|
||||
"六": 6, "七": 7, "八": 8, "九": 9, "十": 10}
|
||||
if value == "十":
|
||||
return 10
|
||||
if value.startswith("十"):
|
||||
return 10 + digits.get(value[1:], 0)
|
||||
if value.endswith("十"):
|
||||
return digits.get(value[0], 0) * 10
|
||||
if "十" in value:
|
||||
left, right = value.split("十", 1)
|
||||
return digits.get(left, 0) * 10 + digits.get(right, 0)
|
||||
return digits.get(value, 0)
|
||||
|
||||
|
||||
class GoalConversationService:
|
||||
"""Merge explicit facts across a session and persist an internal gap snapshot."""
|
||||
|
||||
def __init__(
|
||||
self, session_factory: SessionFactoryLike = SessionFactory,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
|
||||
async def process(
|
||||
self, *, session_id: str, customer_id: int, trace_id: str, message: str
|
||||
) -> dict[str, Any]:
|
||||
async with self.session_factory() as session, session.begin():
|
||||
session_row = await session.scalar(select(ConversationSession).where(
|
||||
ConversationSession.session_id == session_id,
|
||||
ConversationSession.user_id == customer_id,
|
||||
).with_for_update())
|
||||
if session_row is None:
|
||||
return self._result({}, list(REQUIRED_FIELDS), "partial")
|
||||
messages = list(await session.scalars(select(ConversationMessage).where(
|
||||
ConversationMessage.session_id == session_id,
|
||||
ConversationMessage.customer_id == customer_id,
|
||||
ConversationMessage.role == "user",
|
||||
).order_by(ConversationMessage.created_at, ConversationMessage.id)))
|
||||
merged: dict[str, Any] = {}
|
||||
for row in messages:
|
||||
merged.update(extract_goal_entities(row.content))
|
||||
merged.update(extract_goal_entities(message))
|
||||
missing = [field for field in REQUIRED_FIELDS if field not in merged]
|
||||
status = "complete" if not missing else (
|
||||
"clarification_limit"
|
||||
if session_row.clarification_round >= MAX_CLARIFICATION_ROUNDS
|
||||
else "partial"
|
||||
)
|
||||
if status == "partial":
|
||||
session_row.clarification_round += 1
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
message_row = await session.scalar(select(ConversationMessage).where(
|
||||
ConversationMessage.session_id == session_id,
|
||||
ConversationMessage.customer_id == customer_id,
|
||||
ConversationMessage.trace_id == trace_id,
|
||||
ConversationMessage.role == "user",
|
||||
).order_by(ConversationMessage.id.desc()).limit(1))
|
||||
if message_row is not None:
|
||||
exists = await session.scalar(select(AdvisorGoalConversationExtraction).where(
|
||||
AdvisorGoalConversationExtraction.message_id == message_row.id))
|
||||
if exists is None:
|
||||
session.add(AdvisorGoalConversationExtraction(
|
||||
message_id=message_row.id, session_id=session_id,
|
||||
customer_id=customer_id, extraction_version=EXTRACTION_VERSION,
|
||||
extracted_fields=merged, missing_fields=missing, status=status,
|
||||
created_at=now, updated_at=now,
|
||||
))
|
||||
session.add(InteractionAudit(
|
||||
actor_type="agent", actor_id=customer_id, session_id=session_id,
|
||||
portal="agent", action_type="advisor.goal_gap_evaluated",
|
||||
detail={"trace_id": trace_id, "status": status,
|
||||
"missing_count": len(missing)}, created_at=now,
|
||||
))
|
||||
return self._result(merged, missing, status)
|
||||
|
||||
@staticmethod
|
||||
def _result(fields: dict[str, Any], missing: list[str], status: str) -> dict[str, Any]:
|
||||
return {"fields": fields, "missing": missing, "status": status}
|
||||
|
||||
@staticmethod
|
||||
def customer_prompt(result: dict[str, Any]) -> str:
|
||||
missing = result.get("missing", [])
|
||||
if result.get("status") == "clarification_limit":
|
||||
return "投资目标信息仍不完整,暂时无法继续采集,请转人工顾问协助确认。"
|
||||
if missing:
|
||||
labels = [FIELD_LABELS[field] for field in missing if field in FIELD_LABELS]
|
||||
return "为了形成投资目标,还需要确认:" + "、".join(labels) + "。请一次补充这些信息。"
|
||||
fields = result["fields"]
|
||||
return (
|
||||
f"投资目标信息已收集完整:年化收益目标 {fields['annualized_return_lower_pct']}%-"
|
||||
f"{fields['annualized_return_upper_pct']}%,最大回撤 {fields['max_drawdown_pct']}%,"
|
||||
f"流动性 {fields['liquidity_requirement']},"
|
||||
f"期限 {fields['investment_horizon_months']} 个月,"
|
||||
f"业绩比较基准 {fields['benchmark_name']}。请确认后再生成目标书。"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def should_collect(text: str) -> bool:
|
||||
"""Distinguish goal collection from a request to view an existing goal."""
|
||||
return bool(extract_goal_entities(text)) or bool(re.search(
|
||||
r"制定|设定|创建|填写|采集|规划|目标书", text
|
||||
))
|
||||
+32
-19
@@ -111,6 +111,19 @@ Agent 暴露客户最新的 `confirmed` 目标,未确认目标不会进入后
|
||||
阶段十测试结果:推荐服务专项测试 `3 passed`,全量单元/契约测试 `493 passed, 3 warnings`,
|
||||
Ruff 通过,MyPy(138 个源文件)通过。阶段十提交:`0d42779`;生产数据库和端到端联调待完成。
|
||||
|
||||
阶段十一已完成会话闭环核心:投顾 Agent 支持投资目标、产品推荐、持仓分析、资产配置、
|
||||
行情和对比意图路由;投资目标会话抽取只接受用户明确陈述,按会话合并多轮字段,写入内部
|
||||
`advisor_goal_conversation_extraction` 快照并根据缺口追问。缺口状态使用既有
|
||||
`svc_conversation_session.clarification_round` 持久化,达到 10 轮后转人工;Agent Run 完成
|
||||
时同步保存 `last_intent`。目标抽取记录、会话审计、Agent Run、Outbox、记忆召回和 Episode
|
||||
聚合均沿用公共底座链路,客户响应不返回抽取字段、置信度、内部表名或治理信息。对比意图当前
|
||||
完成安全路由和参数提示,对比计算工具仍是后续待办。
|
||||
|
||||
阶段十一测试结果:专项 `4 passed`;单元/契约 `497 passed, 3 warnings`;Ruff 通过;MyPy
|
||||
(140 个源文件)通过。集成测试 `25 passed, 3 failed, 1 skipped`:失败均为既有测试库状态
|
||||
问题(`chk_config_release_separation` 未按新底座迁移撤下、测试账号外键缺失,以及 UTC
|
||||
测试依赖的数据库状态),不是本阶段代码回归;独立迁移库和端到端会话验收仍待执行。
|
||||
|
||||
## 一、迁移准备
|
||||
|
||||
- [ ] 确认远程仓库可访问。(当前失败:连接 `47.106.207.27:3000` 被拒绝)
|
||||
@@ -369,25 +382,25 @@ python tools/audit_constraints.py
|
||||
|
||||
## 十一、会话闭环
|
||||
|
||||
- [ ] 迁移意图分类。
|
||||
- [ ] 迁移产品推荐意图。
|
||||
- [ ] 迁移持仓分析意图。
|
||||
- [ ] 迁移资产配置意图。
|
||||
- [ ] 迁移对比分析意图。
|
||||
- [ ] 迁移投资目标意图。
|
||||
- [ ] 迁移会话实体抽取。
|
||||
- [ ] 迁移投资目标缺口识别。
|
||||
- [ ] 迁移缺口追问状态。
|
||||
- [ ] 迁移会话状态持久化。
|
||||
- [ ] 迁移记忆召回。
|
||||
- [ ] 迁移 Episode 聚合。
|
||||
- [ ] 迁移 Agent Run 持久化。
|
||||
- [ ] 迁移 Outbox 事件。
|
||||
- [ ] 迁移会话审计。
|
||||
- [ ] 确认用户消息可以形成完整闭环。
|
||||
- [ ] 确认缺少字段时只追问必要信息。
|
||||
- [ ] 确认工具失败时返回可理解的降级结果。
|
||||
- [ ] 完成会话闭环提交 `advisor/conversation`。
|
||||
- [x] 迁移意图分类。(沿用公共 `IntentClassifier`;新增对比意图声明)
|
||||
- [x] 迁移产品推荐意图。
|
||||
- [x] 迁移持仓分析意图。
|
||||
- [x] 迁移资产配置意图。
|
||||
- [x] 迁移对比分析意图。(已完成安全路由和参数提示;计算工具待补)
|
||||
- [x] 迁移投资目标意图。
|
||||
- [x] 迁移会话实体抽取。(`GoalConversationService`,只抽取明确目标字段)
|
||||
- [x] 迁移投资目标缺口识别。(按当前会话所有用户轮次合并)
|
||||
- [x] 迁移缺口追问状态。(使用 `clarification_round`,上限 10 轮)
|
||||
- [x] 迁移会话状态持久化。(保存 `last_intent` 和缺口轮次)
|
||||
- [x] 迁移记忆召回。(复用 `PlatformGovernance` 组合召回)
|
||||
- [x] 迁移 Episode 聚合。(复用 `EpisodeWorker`)
|
||||
- [x] 迁移 Agent Run 持久化。(复用 `AgentPersistenceService`)
|
||||
- [x] 迁移 Outbox 事件。(复用 Agent Run 和记忆事件链路)
|
||||
- [x] 迁移会话审计。(目标缺口评估和运行/工具均写统一审计)
|
||||
- [x] 确认用户消息可以形成完整闭环。(专项 + 单元/契约测试通过)
|
||||
- [x] 确认缺少字段时只追问必要信息。(专项测试覆盖)
|
||||
- [x] 确认工具失败时返回可理解的降级结果。(既有各业务工具降级契约通过)
|
||||
- [x] 完成会话闭环提交 `advisor/conversation`。(真实库端到端验收待完成)
|
||||
|
||||
## 十二、画像标签和漂移复核
|
||||
|
||||
|
||||
@@ -31,4 +31,5 @@ def test_advisor_is_registered_through_the_new_base_factory() -> None:
|
||||
"portfolio_analysis",
|
||||
"asset_allocation",
|
||||
"product_recommend",
|
||||
"comparison",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user