Files
group_fqcd_jr/app/service/product_recommendation_service.py
T

353 lines
15 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"
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,
)
async def published(self, context: RequestContext) -> dict[str, object]:
await AuthorizationService.require(context, "product-recommendation:read:self")
async with self.session_factory() as session:
rows = list(
await session.scalars(
select(ClientFacingContent)
.where(
ClientFacingContent.customer_id == int(context.user_id),
ClientFacingContent.content_type == self.CONTENT_TYPE,
ClientFacingContent.review_status == "approved",
ClientFacingContent.published_at.is_not(None),
)
.order_by(ClientFacingContent.published_at.desc())
.limit(20)
)
)
return {
"data": [
{
"content_id": str(row.id),
"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
)