"""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 )