Files
group_fqcd_jr/app/service/product_recommendation_service.py
T
Windows 74b7d00dff feat(投顾): 方案交付落点、可视化图表与推荐依据的大模型增强
交付落点
- 新增 GET /api/v1/users/me/advisor-contents(客户读**自己**已发布方案):
  「发送给客户」原先只改数据状态、客户端没有任何页面或接口能读到它
- 客户端新增「我的投顾方案」页与导航入口

可视化(投顾结果区与客户页**共用** common/advisor-plan-view.js,避免两处漂移)
- 净值折线图(带坐标轴与网格)、组合业绩等权合成曲线(含区间收益与最大回撤)、
  资产配置环形图与图例、组合构成条
- 修 num(null)=0 的假 0:Number(null)/Number('') 会得 0,导致「没数据」被渲染成 0.00%;
  现一律显示「--」。同理管理费/起投未维护时按没数据处理,不显示 0
- 涨跌口径为「涨红跌绿」(A 股习惯),由 CSS 变量 --plan-up / --plan-down 集中定义

推荐依据接入大模型(可选,失败即回退)
- 新增 AdvisorReasonService:**只改文案,不参与选品**(候选池与排序在它之前已固定)
- 输入只允许是已算出的真实参数(风险等级、排序得分、区间收益、最大回撤、期限与流动性)
- 命中收益承诺词(保本/保证收益/稳赚/无风险…)整条丢弃并回退规则文案
- 未启用 / 缺密钥 / 超时 / 解析失败一律回退,推荐主流程不因模型不可用而失败
- 前端标注来源(AI 生成 / 规则生成)

数据与权限
- 客户角色补齐:绑 customer 角色、补建缺失的账户与交易段权限码(9060-9065)
- 净值全量同步(20 只产品),行情同步脚本按 --codes 分块(全量一次会被超时终止)

测试
- 新增 tests/unit/service/test_advisor_reason_service.py(10 项,专测三条合规边界)
- 前端模块自检纳入 service-request-module;补「两处共用同一渲染」回归测试
2026-09-16 18:17:47 +08:00

731 lines
34 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."""
import logging
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 (
ForbiddenAgentError,
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.fund import FundNavHistory
from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent
from app.repository.advisor_product_repository import (
AdvisorProductRepository,
AuthoritativeProductCandidate,
)
from app.service.advisor_reason_service import AdvisorReasonService
from app.service.api_transaction_service import ApiTransactionService
from app.service.authorization_service import AuthorizationService
from app.service.investment_goal_service import InvestmentGoalService
# 流动性要求的中文文案与投资目标线**同源**(`investment_goal_service._LIQUIDITY_LABELS`):
# 这里直接复用,避免两处各翻一遍、日后口径漂移。
from app.service.investment_goal_service import _LIQUIDITY_LABELS
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
#: 投资方案书的 `content_type`。它与推荐方案同处 `client_facing_content` 表,
#: 由 `InvestmentGoalService` 写入;两者的**后续动作用不同键寻址**:
#: 推荐方案用 `content_id`,方案书用 `goal_no`。
BOOK_CONTENT_TYPE = "investment_goal_book"
logger = logging.getLogger(__name__)
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")
#: 两类内容的"待审"取值同样不同:推荐方案生成时置 `pending_review`
#: (`ProductRecommendationService.generate`),方案书创建草稿时置 `pending`
#: (`InvestmentGoalService.create`)。只判其中一个会把另一类整类漏掉。
PENDING_STATES: tuple[str, ...] = ("pending", "pending_review")
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]:
# 代客:payload.customer_id 指定被分析客户;留空则以登录用户自身为对象(原行为)。
customer_id = payload.customer_id or int(context.user_id)
if customer_id == int(context.user_id):
await AuthorizationService.require(context, "product-recommendation:generate:self")
else:
await AuthorizationService.require_customer_scope(
context, "product-recommendation:generate:customer", customer_id
)
if self.enforce_profile_governance:
await ProfileGovernanceService().require_operable(customer_id)
authority = await SuitabilityService().authority_for_customer(customer_id)
if authority.customer_risk_level is None:
return {"status": "profile_required"}
goal = await InvestmentGoalService().current_for_customer(customer_id, 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(customer_id)
products = [
self._view(item, index, goal, graph_context)
for index, item in enumerate(selected, start=1)
]
# 「推荐依据」的 LLM 增强:把**已经算出来的真实参数**交给模型,写成客户读得懂的说明。
# 未启用 / 缺密钥 / 调用失败 / 未过合规守卫时,保留 `_view` 的确定性文案。
# 注意:模型只改文案,**不参与选品**(候选池与排序在它之前就已固定)。
products = await self._enhance_reasons(
products, selected, goal, authority.customer_risk_level
)
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=customer_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=customer_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:{customer_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
# 推荐依据要给出**这一只**的真实参数,而不是每只都同一句套话。
# 全部取自本次计算过程(硬约束、适当性等级、目标的期限/流动性、排序得分),
# 不做任何收益承诺 —— 文案里不出现"稳赚/保本"这类词。
liquidity_raw = str(goal.get("liquidity_requirement") or "")
liquidity_label = _LIQUIDITY_LABELS.get(liquidity_raw, liquidity_raw or "--")
reason = (
"已通过三项硬约束:场内可交易 / 权威适当性证据 / 基金合同快照;"
f"风险等级 {candidate.suitability.risk_level},与客户风险承受力匹配;"
f"按投资期限 {goal.get('investment_horizon_months')} 个月、"
f"流动性要求「{liquidity_label}」筛入;"
f"综合排序得分 {round(score, 4)}。"
)
return {
"rank": rank,
"product_code": product.product_code,
"product_name": product.product_name,
"product_category": product.product_category,
"reason": 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 _performance_snapshot(
self, product_ids: list[int]
) -> dict[int, dict[str, float | None]]:
"""每只产品的近 20/60 个交易日区间收益与近 60 日最大回撤(`fin_nav_history`)。
这些数字是「推荐依据」的**事实来源**:既给确定性文案,也给大模型当输入,
避免模型自己编业绩。表里没数据时对应值为 None(文案与提示词都显示「暂无」)。
"""
if not product_ids:
return {}
snapshot: dict[int, dict[str, float | None]] = {
pid: {"return_20d_pct": None, "return_60d_pct": None, "max_drawdown_60d_pct": None}
for pid in product_ids
}
async with self.session_factory() as session:
rows = (
await session.execute(
select(FundNavHistory.product_id, FundNavHistory.nav)
.where(FundNavHistory.product_id.in_(product_ids))
.order_by(FundNavHistory.product_id, FundNavHistory.nav_date.asc())
)
).all()
grouped: dict[int, list[float]] = {}
for product_id, nav in rows:
grouped.setdefault(int(product_id), []).append(float(nav))
for product_id, values in grouped.items():
snapshot[int(product_id)] = {
"return_20d_pct": _interval_return(values, 20),
"return_60d_pct": _interval_return(values, 60),
"max_drawdown_60d_pct": _max_drawdown_pct(values[-61:]),
}
return snapshot
async def _enhance_reasons(
self,
products: list[dict[str, object]],
selected: list[tuple[AuthoritativeProductCandidate, float]],
goal: dict[str, object],
customer_risk_level: int,
) -> list[dict[str, object]]:
"""给每只产品标 `reason_source`(llm / rule),并在可用时替换成 LLM 文案。"""
snapshot: dict[int, dict[str, float | None]] = {}
try:
snapshot = await self._performance_snapshot(
[int(candidate.product.id) for candidate, _ in selected]
)
except Exception: # noqa: BLE001 — 读不到历史不该影响出方案
logger.warning("推荐依据:历史净值读取失败,按「暂无」处理", exc_info=True)
prompt_products: list[dict[str, object]] = []
for index, product in enumerate(products):
product_id = int(selected[index][0].product.id) if index < len(selected) else None
facts = snapshot.get(product_id, {}) if product_id is not None else {}
product["performance"] = facts
prompt_products.append({**product, **facts})
liquidity_raw = str(goal.get("liquidity_requirement") or "")
generated = await AdvisorReasonService().enhance(
customer={
"risk_level": customer_risk_level,
"horizon_months": goal.get("investment_horizon_months"),
"liquidity": _LIQUIDITY_LABELS.get(liquidity_raw, liquidity_raw),
},
products=prompt_products,
)
for product in products:
code = str(product.get("product_code"))
if code in generated:
product["reason"] = generated[code]
product["reason_source"] = "llm"
else:
product["reason_source"] = "rule"
return products
async def _graph_context(self, customer_id: int) -> dict[str, object]:
if self.relationship_service is None:
return {"degraded": True, "reason": "graph_not_configured"}
return await self.relationship_service.portfolio_industry_context(customer_id)
async def review(
self,
content_id: int,
decision: str,
comment: str,
context: RequestContext,
key: str | None,
) -> dict[str, object]:
# 审核权**只按权限码**,不再额外要求 admin 角色(2026-09-14 业务要求:
# 投顾要能自己审核、发布自己生成的方案,否则草案永远停在 pending_review,
# 演示/生产都得切到管理员账号才能推进)。
# 安全边界仍在:`product-recommendation:review` 目前只授予 advisor 与 admin
# 两个角色(`tools/grant_advisor_role.py` + 种子的 ADMIN_PERMISSIONS),
# 且 `reviewer_user_id` 如实落库,审计可追。
# ⚠️ 若合规上要求"四眼原则",把 `admin=True` 加回本行即可恢复管理员专属
# (管理面复核队列 `pending_reviews` 仍保持 admin 专属,未放宽)。
await AuthorizationService.require(context, "product-recommendation:review")
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]:
# 同上:发布权按权限码判定(advisor 与 admin 均持有),不再要求 admin 角色。
# 恢复到"管理员专属"只需把 `admin=True` 加回。
await AuthorizationService.require(context, "product-recommendation:publish")
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,
)
async def delete(
self, content_id: int, context: RequestContext, key: str | None
) -> dict[str, object]:
"""删除推荐方案(投顾工作台「历史方案记录」里的「删除」)。
## 为什么只允许删推荐方案
投资方案书(`BOOK_CONTENT_TYPE`)被 `advisor_investment_goal.goal_book_content_id`
以 `NO ACTION` 外键引用,且该列 **`NOT NULL`** —— 硬删方案书必然撞外键(1451)。
方案书有自己的 `goal_no` 生命周期,不走这里。
## 归属
复用 `_visible_customer_ids`(本人 + 名下归属客户),与 `published` / `history`
同一把尺子:不是自己能看的客户,方案也删不得。
"""
await AuthorizationService.require(context, "product-recommendation:delete")
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.customer_id not in self._visible_customer_ids(context):
raise ForbiddenAgentError("无权操作该客户的方案")
now = datetime.now(UTC).replace(tzinfo=None)
await session.delete(content)
# 删除也留痕:`interaction_audit` 与 `client_facing_content` 无外键,
# 方案没了审计仍在(合规要求「删了什么、谁删的」可追)。
session.add(
InteractionAudit(
actor_type="user",
actor_id=int(context.user_id),
target_customer_id=content.customer_id,
portal=context.portal,
action_type="advisor.recommendation_deleted",
detail={
"content_id": str(content_id),
"content_type": self.CONTENT_TYPE,
"trace_id": context.trace_id,
},
created_at=now,
)
)
await session.flush()
return {
"data": {"content_id": str(content_id), "status": "deleted"},
"meta": {"trace_id": context.trace_id},
}
return await ApiTransactionService().execute(
context,
f"advisor:recommendations:{content_id}:delete",
key,
{"delete": 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 my_published(self, context: RequestContext) -> dict[str, object]:
"""客户视角:**自己**已被审核发布的投顾交付物(`/api/v1/users/me/advisor-contents`)。
与 `published()`(投顾侧)的区别在**范围**:这里只看 `customer_id == 自己`,
不掺 `sys_customer_assignment` —— 那是投顾的归属概念,客户没有归属客户。
只返回 `published_at` 非空的:投顾「发送给客户」之前,客户看不到。
"""
await AuthorizationService.require(context, "product-recommendation:read:self")
customer_id = int(context.user_id)
async with self.session_factory() as session:
rows = list(
await session.scalars(
select(ClientFacingContent)
.where(
ClientFacingContent.customer_id == customer_id,
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(50)
)
)
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 history(self, context: RequestContext) -> dict[str, object]:
"""投顾本人的方案留档:本人 + 名下归属客户的**全部状态**方案与方案书。
与 `published()` 的唯一差别是**状态口径**:`published` 只给已发布(供客户看),
`history` 给全部(含 `pending_review`/`pending` 待审、`rejected` 已驳回),
供投顾在工作台回看"以前生成过什么"。归属过滤复用 `_visible_customer_ids`,
与 `published` 保持同一把尺子。
"""
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),
)
.order_by(ClientFacingContent.created_at.desc())
.limit(50)
)
)
return {
"data": [
{
"content_id": str(row.id),
"customer_id": str(row.customer_id),
"content_type": row.content_type,
"review_status": row.review_status,
"plan": row.draft_content,
"created_at": row.created_at.isoformat() if row.created_at else None,
"reviewed_at": row.reviewed_at.isoformat() if row.reviewed_at else None,
"published_at": row.published_at.isoformat() if row.published_at else None,
}
for row in rows
],
"meta": {"trace_id": context.trace_id},
}
async def pending_reviews(self, context: RequestContext) -> dict[str, object]:
"""管理面复核队列:待审核的推荐方案与投资方案书。
## 为什么必须补这个入口
`review` / `publish` 都要求调用方**先知道 `content_id`**,而在此之前
**没有任何端点能列出待审内容** —— 管理员拿不到 id,整条审核链路实际不可达:
投顾生成草案后它会一直停在待审状态,没有人能推进它。
## 口径
- **不按客户归属过滤**:这是管理面的复核队列,管理员要看**全部**待审内容;
权限由 `product-recommendation:review`(`admin=True`)把关,
比 `published` 用的 `...:read:self` 更严。
- **一次返回两类内容**(推荐方案 + 方案书),前端按 `content_type` 区分。
它们同处 `client_facing_content` 表,只是 `review_status` 取值不同。
- 按 `created_at` **升序**:先提交的先审,避免新草案把旧的挤下去。
"""
await AuthorizationService.require(
context, "product-recommendation:review", admin=True
)
async with self.session_factory() as session:
rows = list(
await session.scalars(
select(ClientFacingContent)
.where(
ClientFacingContent.content_type.in_(self.CLIENT_CONTENT_TYPES),
ClientFacingContent.review_status.in_(self.PENDING_STATES),
)
.order_by(ClientFacingContent.created_at.asc())
.limit(50)
)
)
# ⚠️ 两类内容的后续动作用**不同的键**寻址:
# · 推荐方案:`content_id` → A045 / A046
# · 投资方案书:`goal_no` → AD006 / AD007
# 待审列表本身只有 `content_id`,所以这里为方案书一并查出 `goal_no`;
# 否则管理员拿到了列表也调不动那两个端点(缺的就是这个映射)。
book_ids = [row.id for row in rows if row.content_type == BOOK_CONTENT_TYPE]
goal_nos: dict[int, str] = {}
if book_ids:
pairs = await session.execute(
select(
AdvisorInvestmentGoal.goal_book_content_id,
AdvisorInvestmentGoal.goal_no,
).where(AdvisorInvestmentGoal.goal_book_content_id.in_(book_ids))
)
goal_nos = {int(content_id): str(no) for content_id, no in pairs.all()}
return {
"data": [
{
"content_id": str(row.id),
"customer_id": str(row.customer_id),
"content_type": row.content_type,
"review_status": row.review_status,
"plan": row.draft_content,
"created_at": row.created_at.isoformat() if row.created_at else None,
# 仅方案书有值;推荐方案为 None(它按 content_id 寻址)
"goal_no": goal_nos.get(row.id),
}
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
)
def _interval_return(values: list[float], trading_days: int) -> float | None:
"""近 N 个交易日的区间收益(%);数据不足或基准为 0 时返回 None。"""
if len(values) < 2:
return None
base = values[max(0, len(values) - 1 - trading_days)]
if not base:
return None
return (values[-1] - base) / base * 100
def _max_drawdown_pct(values: list[float]) -> float | None:
"""区间最大回撤(%,负值);数据不足返回 None。"""
if len(values) < 2:
return None
peak = values[0]
worst = 0.0
for value in values:
peak = max(peak, value)
if peak:
worst = min(worst, (value - peak) / peak * 100)
return worst