feat: complete advisor conversation loop

This commit is contained in:
Windows
2026-09-11 16:12:38 +08:00
parent e8ec0af398
commit 4088c634de
7 changed files with 450 additions and 19 deletions
+26
View File
@@ -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
+8
View File
@@ -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",
+228
View File
@@ -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
View File
@@ -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)