组员这次提交的两套新页面方向都对,但各有一处"接不上"的地方,这里补齐。 1) 访客客服浮窗(此前一问即失败) 客服 Agent 让访客走 query_knowledge(访客令牌的角色是 visitor、权限只有 agent:run + knowledge:query),但发布配置里三个知识意图只发了 search_knowledge, 于是 ToolExecutor 直接抛 ForbiddenAgentError,而客服代码对白名单失败是 「必须冒泡」的 —— 访客拿不到任何回答,登录客户侧却完全正常。 发布脚本的知识类意图改为同时发 search_knowledge 与 query_knowledge: 访客走前者、客户走后者,缺任一条对应人群就失败关闭。 (suitability_check 不发 query_knowledge:访客意图白名单不含它,访客到不了。) 2) 发布脚本会静默丢提示词 旧写法只查 platform_config_item 就当作"继承",而 config_release 是整版本替换 语义,新版本没带上的行等于被删除 —— 实际把 customer_service_chitchat 提示词 漏在了旧版本里(admin 端只在激活时打一句 stderr 警告)。 改为走 ConfigReleaseService.effective_snapshot() 读全三张受管表,补上提示词 搬运(version 重分配、带上 input_schema/output_schema),并在激活后硬校验 配置项与提示词条数,条数不符即失败退出。 (model_routing_rule 本环境为空;不为空则直接中止,不假装支持。) 丢失的那条提示词已按原文恢复,active 版本现为 9 条配置项 + 1 条提示词。 3) 投顾工作台永远为空 published() 取的是 customer_id == 自己 user_id,而投顾是员工账号、不可能是 客户;且只认 advisor_recommendation_plan + approved,而投顾交付的主产物是 investment_goal_book,发布后状态是 published。三重不匹配下页面永远显示空态。 改为按「本人 + sys_customer_assignment 里名下归属客户」过滤(不用 data_scope: 投顾因持有 all 级权限会把整个身份的 scope 抬到 all,那会放开到全部客户), 并覆盖两类 content_type 与两种已发布取值。 实测:投顾可见归属客户 9001 的方案书,客户仍只见自己的,风控仍 403。 4) 访客页把"演示数据"声明删了但假数据还在 mock-data.js 的 MOCK_SOURCE_NOTICE 与两个页面的 data-source-notice 区块被删除, 而 MOCK_PRODUCTS/MOCK_RANKING_CHANGE 仍在渲染(详情页含历史净值曲线)。 恢复声明常量、页面区块与样式,并给 products/product-detail 的 link 与 script 加上版本参数 —— 此前没有版本号,浏览器会命中旧缓存,改动看不见。 其他:投顾页显示交付物类型与客户编号(后端新返回的字段),README 补上投顾页 数据口径、访客/客户两条检索工具的差别,以及"渲染 mock 必须带来源声明"的约定。
379 lines
17 KiB
Python
379 lines
17 KiB
Python
"""Constraint-first recommendations for the exchange-traded simulation domain."""
|
||
|
||
from collections.abc import Callable
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select
|
||
|
||
from app.core.config import get_settings
|
||
from app.core.contracts import RequestContext
|
||
from app.core.errors import GenericResourceNotFoundError, InvalidStateError
|
||
from app.core.product_recommendation_contracts import ProductRecommendationQuery
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.investment_goal import ClientFacingContent
|
||
from app.repository.advisor_product_repository import (
|
||
AdvisorProductRepository,
|
||
AuthoritativeProductCandidate,
|
||
)
|
||
from app.service.api_transaction_service import ApiTransactionService
|
||
from app.service.authorization_service import AuthorizationService
|
||
from app.service.investment_goal_service import InvestmentGoalService
|
||
from app.service.product_governance_monitor_service import SALES_INSTITUTION
|
||
from app.service.profile_governance_service import ProfileGovernanceService
|
||
from app.service.relationship_service import RelationshipService
|
||
from app.service.suitability_service import SuitabilityService
|
||
|
||
|
||
class ProductRecommendationService:
|
||
CONTENT_TYPE = "advisor_recommendation_plan"
|
||
#: 面向客户展示的投顾内容类型。**方案书(goal book)是本项目投顾交付的主产物**,
|
||
#: 由 `InvestmentGoalService` 写入同一张 `client_facing_content` 表,靠 `content_type`
|
||
#: 区分。投顾工作台只认 recommendation 时永远为空 —— 因为投顾给客户交付的是方案书。
|
||
CLIENT_CONTENT_TYPES: tuple[str, ...] = ("advisor_recommendation_plan", "investment_goal_book")
|
||
#: 两类内容的"已发布"在库里取值不同:recommendation 审核通过后置 `approved`
|
||
#: (`product_recommendation_service.review`),方案书发布后置 `published`
|
||
#: (`investment_goal_service.publish_book`)。只判 `approved` 会把方案书整类漏掉。
|
||
PUBLISHED_STATES: tuple[str, ...] = ("approved", "published")
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
session_factory: Callable[[], Any] = SessionFactory,
|
||
relationship_service: RelationshipService | None = None,
|
||
enforce_profile_governance: bool = False,
|
||
) -> None:
|
||
self.session_factory = session_factory
|
||
self.relationship_service = relationship_service or RelationshipService(
|
||
Neo4jGraphDriver(get_settings())
|
||
)
|
||
self.enforce_profile_governance = enforce_profile_governance
|
||
|
||
async def generate(
|
||
self, payload: ProductRecommendationQuery, context: RequestContext, key: str | None
|
||
) -> dict[str, object]:
|
||
await AuthorizationService.require(context, "product-recommendation:generate:self")
|
||
if self.enforce_profile_governance:
|
||
await ProfileGovernanceService().require_operable(int(context.user_id))
|
||
authority = await SuitabilityService().authority_for_customer(int(context.user_id))
|
||
if authority.customer_risk_level is None:
|
||
return {"status": "profile_required"}
|
||
goal = await InvestmentGoalService().current_for_agent(context)
|
||
if goal is None:
|
||
return {"status": "investment_goal_required"}
|
||
candidates, excluded = await self._candidates(
|
||
authority.customer_risk_level, str(goal["liquidity_requirement"])
|
||
)
|
||
horizon = goal.get("investment_horizon_months")
|
||
if not isinstance(horizon, int):
|
||
return {"status": "recommendation_input_invalid"}
|
||
ranked = self._rank(
|
||
candidates, authority.customer_risk_level, horizon
|
||
)
|
||
selected = ranked[: payload.limit]
|
||
excluded.extend(self._ranking_exclusions(ranked[payload.limit :], payload.limit))
|
||
graph_context = await self._graph_context(context)
|
||
products = [
|
||
self._view(item, index, goal, graph_context)
|
||
for index, item in enumerate(selected, start=1)
|
||
]
|
||
plan = {
|
||
"document_type": "advisor_recommendation_plan",
|
||
"document_version": "1.0",
|
||
"products": products,
|
||
"excluded_candidates": excluded,
|
||
"selection_summary": {
|
||
"candidate_count": len(candidates) + len(excluded),
|
||
"selected_count": len(products),
|
||
"excluded_count": len(excluded),
|
||
},
|
||
"graph_context": graph_context,
|
||
"disclosures": [
|
||
"推荐结果仅供场内基金模拟交易分析,不构成交易指令。",
|
||
"历史数据和风险等级不代表未来收益,收益目标不构成承诺。",
|
||
"推荐方案须经审核发布后方可对客户展示。",
|
||
],
|
||
}
|
||
if key is None:
|
||
return {"status": "ready", **plan, "analysis_only": True}
|
||
|
||
async def operation(session: Any) -> dict[str, object]:
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
content = ClientFacingContent(
|
||
customer_id=int(context.user_id),
|
||
content_type=self.CONTENT_TYPE,
|
||
draft_content=plan,
|
||
generated_by_portal=context.portal,
|
||
review_status="pending_review",
|
||
reviewer_user_id=None,
|
||
reviewed_at=None,
|
||
published_at=None,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
session.add(content)
|
||
session.add(
|
||
InteractionAudit(
|
||
actor_type="user",
|
||
actor_id=int(context.user_id),
|
||
target_customer_id=int(context.user_id),
|
||
portal=context.portal,
|
||
action_type="advisor.recommendation_created",
|
||
detail={
|
||
"content_type": self.CONTENT_TYPE,
|
||
"status": "pending_review",
|
||
"trace_id": context.trace_id,
|
||
},
|
||
created_at=now,
|
||
)
|
||
)
|
||
await session.flush()
|
||
return {
|
||
"data": {
|
||
"content_id": str(content.id),
|
||
"status": content.review_status,
|
||
"plan": plan,
|
||
},
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|
||
|
||
return await ApiTransactionService().execute(
|
||
context,
|
||
f"advisor:recommendations:{context.user_id}",
|
||
key,
|
||
payload.model_dump(mode="json"),
|
||
operation,
|
||
)
|
||
|
||
async def _candidates(
|
||
self, customer_risk_level: int, liquidity_requirement: str
|
||
) -> tuple[list[AuthoritativeProductCandidate], list[dict[str, object]]]:
|
||
async with self.session_factory() as session:
|
||
candidates = await AdvisorProductRepository(session).authoritative_tradable_products(
|
||
datetime.now(UTC).replace(tzinfo=None),
|
||
sales_institution=SALES_INSTITUTION,
|
||
liquidity_requirement=liquidity_requirement,
|
||
limit=50,
|
||
)
|
||
return AdvisorProductRepository.hard_suitability_filter(candidates, customer_risk_level)
|
||
|
||
@staticmethod
|
||
def _rank(
|
||
candidates: list[AuthoritativeProductCandidate], risk: int, horizon: int
|
||
) -> list[tuple[AuthoritativeProductCandidate, float]]:
|
||
def score(candidate: AuthoritativeProductCandidate) -> float:
|
||
level = int(candidate.suitability.risk_level.removeprefix("R"))
|
||
risk_score = 1 - abs(risk - level) / 4
|
||
liquidity = candidate.liquidity
|
||
if liquidity is None or liquidity.average_daily_turnover_amount is None:
|
||
liquidity_score = 0.5
|
||
else:
|
||
liquidity_score = min(
|
||
1.0, float(liquidity.average_daily_turnover_amount / 10_000_000)
|
||
)
|
||
term_score = (
|
||
0.8
|
||
if horizon >= 36 and candidate.product.product_category in {"ETF", "LOF"}
|
||
else 0.6
|
||
)
|
||
return 0.55 * risk_score + 0.25 * liquidity_score + 0.20 * term_score
|
||
|
||
return sorted(
|
||
((candidate, score(candidate)) for candidate in candidates),
|
||
key=lambda item: (-item[1], item[0].product.product_code),
|
||
)
|
||
|
||
@staticmethod
|
||
def _ranking_exclusions(
|
||
ranked: list[tuple[AuthoritativeProductCandidate, float]], limit: int
|
||
) -> list[dict[str, object]]:
|
||
return [
|
||
{
|
||
"product_code": candidate.product.product_code,
|
||
"product_name": candidate.product.product_name,
|
||
"stage": "ranking",
|
||
"reason_code": "RANKED_BELOW_SELECTION_LIMIT",
|
||
"reason": "产品通过硬性约束但排序低于本次选择数量。",
|
||
"ranking_score": round(score, 4),
|
||
"selection_limit": limit,
|
||
}
|
||
for candidate, score in ranked
|
||
]
|
||
|
||
@staticmethod
|
||
def _view(
|
||
item: tuple[AuthoritativeProductCandidate, float],
|
||
rank: int,
|
||
goal: dict[str, object],
|
||
graph_context: dict[str, object],
|
||
) -> dict[str, object]:
|
||
candidate, score = item
|
||
product = candidate.product
|
||
contract = candidate.contract
|
||
return {
|
||
"rank": rank,
|
||
"product_code": product.product_code,
|
||
"product_name": product.product_name,
|
||
"product_category": product.product_category,
|
||
"reason": "该产品已通过场内可交易、权威适当性和合同证据校验,"
|
||
"并与已确认投资目标的期限和流动性要求相匹配。",
|
||
"score": round(score, 4),
|
||
"recommendation_evidence_card": {
|
||
"card_version": "1.0",
|
||
"hard_constraints": [
|
||
"exchange_traded",
|
||
"suitability_verified",
|
||
"contract_verified",
|
||
],
|
||
"suitability": {
|
||
"risk_level": candidate.suitability.risk_level,
|
||
"source_url": candidate.suitability.source_url,
|
||
"document_title": candidate.suitability.document_title,
|
||
},
|
||
"contract": {
|
||
"fund_type": contract.fund_type,
|
||
"source_url": contract.source_url,
|
||
"document_title": contract.document_title,
|
||
},
|
||
"liquidity": {
|
||
"status": candidate.liquidity.status if candidate.liquidity else "unknown",
|
||
"average_daily_turnover_amount": str(
|
||
candidate.liquidity.average_daily_turnover_amount
|
||
)
|
||
if candidate.liquidity
|
||
and candidate.liquidity.average_daily_turnover_amount is not None
|
||
else None,
|
||
},
|
||
"goal_constraints": {
|
||
"liquidity_requirement": goal["liquidity_requirement"],
|
||
"investment_horizon_months": goal["investment_horizon_months"],
|
||
},
|
||
"graph_status": "degraded" if graph_context.get("degraded") else "available",
|
||
},
|
||
}
|
||
|
||
async def _graph_context(self, context: RequestContext) -> dict[str, object]:
|
||
if self.relationship_service is None:
|
||
return {"degraded": True, "reason": "graph_not_configured"}
|
||
return await self.relationship_service.portfolio_industry_context(int(context.user_id))
|
||
|
||
async def review(
|
||
self,
|
||
content_id: int,
|
||
decision: str,
|
||
comment: str,
|
||
context: RequestContext,
|
||
key: str | None,
|
||
) -> dict[str, object]:
|
||
await AuthorizationService.require(context, "product-recommendation:review", admin=True)
|
||
|
||
async def operation(session: Any) -> dict[str, object]:
|
||
content = await session.get(ClientFacingContent, content_id, with_for_update=True)
|
||
if content is None or content.content_type != self.CONTENT_TYPE:
|
||
raise GenericResourceNotFoundError("推荐方案不存在")
|
||
if content.review_status != "pending_review":
|
||
raise InvalidStateError("推荐方案当前不能审核")
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
content.review_status = "approved" if decision == "approved" else "rejected"
|
||
content.reviewer_user_id = int(context.user_id)
|
||
content.reviewed_at = now
|
||
content.updated_at = now
|
||
content.draft_content = {**content.draft_content, "review_comment": comment}
|
||
await session.flush()
|
||
return {
|
||
"data": {"content_id": str(content.id), "status": content.review_status},
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|
||
|
||
return await ApiTransactionService().execute(
|
||
context,
|
||
f"advisor:recommendations:{content_id}:review",
|
||
key,
|
||
{"decision": decision, "comment": comment},
|
||
operation,
|
||
)
|
||
|
||
async def publish(
|
||
self, content_id: int, context: RequestContext, key: str | None
|
||
) -> dict[str, object]:
|
||
await AuthorizationService.require(context, "product-recommendation:publish", admin=True)
|
||
|
||
async def operation(session: Any) -> dict[str, object]:
|
||
content = await session.get(ClientFacingContent, content_id, with_for_update=True)
|
||
if content is None or content.content_type != self.CONTENT_TYPE:
|
||
raise GenericResourceNotFoundError("推荐方案不存在")
|
||
if content.review_status != "approved":
|
||
raise InvalidStateError("推荐方案审核通过后才能发布")
|
||
content.review_status = "approved"
|
||
content.published_at = datetime.now(UTC).replace(tzinfo=None)
|
||
content.updated_at = content.published_at
|
||
await session.flush()
|
||
return {
|
||
"data": {"content_id": str(content.id), "status": "published"},
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|
||
|
||
return await ApiTransactionService().execute(
|
||
context,
|
||
f"advisor:recommendations:{content_id}:publish",
|
||
key,
|
||
{"publish": True},
|
||
operation,
|
||
)
|
||
|
||
@staticmethod
|
||
def _visible_customer_ids(context: RequestContext) -> tuple[int, ...]:
|
||
"""可查看的客户 id:本人 + 名下归属客户。
|
||
|
||
为什么不用 `data_scope`:投顾/运营因为持有 `promotion:*` 这类 all 级权限,
|
||
`IdentityService` 会把**整个身份**的 scope 抬到 `all`(`identity_repository` 取各授权
|
||
scope 的最大值)。按 scope 判定会让他们看到全部客户的方案,属过度开放。
|
||
归属关系来自 `sys_customer_assignment`(逐条授权,且带 assigned_at/unassigned_at
|
||
时间窗校验),比 scope 更窄,也更贴合"投顾只看自己服务的客户"这个业务口径。
|
||
"""
|
||
ids = {str(context.user_id), *(str(item) for item in context.customer_ids)}
|
||
return tuple(sorted({int(item) for item in ids if item.strip().isdigit()}))
|
||
|
||
async def published(self, context: RequestContext) -> dict[str, object]:
|
||
await AuthorizationService.require(context, "product-recommendation:read:self")
|
||
customer_ids = self._visible_customer_ids(context)
|
||
if not customer_ids:
|
||
return {"data": [], "meta": {"trace_id": context.trace_id}}
|
||
async with self.session_factory() as session:
|
||
rows = list(
|
||
await session.scalars(
|
||
select(ClientFacingContent)
|
||
.where(
|
||
ClientFacingContent.customer_id.in_(customer_ids),
|
||
ClientFacingContent.content_type.in_(self.CLIENT_CONTENT_TYPES),
|
||
ClientFacingContent.review_status.in_(self.PUBLISHED_STATES),
|
||
ClientFacingContent.published_at.is_not(None),
|
||
)
|
||
.order_by(ClientFacingContent.published_at.desc())
|
||
.limit(20)
|
||
)
|
||
)
|
||
return {
|
||
"data": [
|
||
{
|
||
"content_id": str(row.id),
|
||
"customer_id": str(row.customer_id),
|
||
"content_type": row.content_type,
|
||
"plan": row.draft_content,
|
||
"published_at": row.published_at.isoformat() if row.published_at else None,
|
||
}
|
||
for row in rows
|
||
],
|
||
"meta": {"trace_id": context.trace_id},
|
||
}
|
||
|
||
|
||
async def product_recommendation_tool(
|
||
arguments: ProductRecommendationQuery, context: RequestContext
|
||
) -> dict[str, object]:
|
||
return await ProductRecommendationService(enforce_profile_governance=True).generate(
|
||
arguments, context, None
|
||
)
|