Files
group_fqcd_jr/app/service/product_recommendation_service.py
T
lzf_0626 d4a895c643 fix: 修好访客客服浮窗、投顾工作台数据口径,恢复访客页来源声明
组员这次提交的两套新页面方向都对,但各有一处"接不上"的地方,这里补齐。

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 必须带来源声明"的约定。
2026-09-13 18:54:09 +08:00

379 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
)