diff --git a/app/api/controllers/investment_goals.py b/app/api/controllers/investment_goals.py new file mode 100644 index 0000000..47538fa --- /dev/null +++ b/app/api/controllers/investment_goals.py @@ -0,0 +1,83 @@ +"""Investment-goal collection and goal-book review endpoints.""" + +from typing import Any + +from fastapi import APIRouter, Depends, Header, status + +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.rate_limit import enforce_rate_limit +from app.api.schemas.investment_goals import ( + InvestmentGoalBookPublish, + InvestmentGoalBookReview, + InvestmentGoalConfirmation, + InvestmentGoalCreate, +) +from app.core.contracts import RequestContext +from app.service.investment_goal_service import InvestmentGoalService + +router = APIRouter( + prefix="/api/v1/advisor", tags=["advisor-investment-goals"], + dependencies=[Depends(enforce_rate_limit)], +) + + +@router.post("/investment-goals", status_code=status.HTTP_201_CREATED) +async def create_investment_goal( + payload: InvestmentGoalCreate, + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + return await InvestmentGoalService().create(payload, context, key) + + +@router.get("/investment-goals/current") +async def current_own_investment_goal( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + return await InvestmentGoalService().current(int(context.user_id), context) + + +@router.get("/customers/{customer_id}/investment-goals/current") +async def current_customer_investment_goal( + customer_id: int, + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + return await InvestmentGoalService().current(customer_id, context) + + +@router.post("/investment-goals/{goal_no}/confirmations") +async def confirm_investment_goal( + goal_no: str, + payload: InvestmentGoalConfirmation, + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + return await InvestmentGoalService().confirm(goal_no, context, key) + + +@router.get("/investment-goals/{goal_no}/goal-book") +async def investment_goal_book( + goal_no: str, + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, Any]: + return await InvestmentGoalService().goal_book(goal_no, context) + + +@router.post("/investment-goals/{goal_no}/goal-book/reviews") +async def review_investment_goal_book( + goal_no: str, + payload: InvestmentGoalBookReview, + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + return await InvestmentGoalService().review_book(goal_no, payload, context, key) + + +@router.post("/investment-goals/{goal_no}/goal-book/publications") +async def publish_investment_goal_book( + goal_no: str, + payload: InvestmentGoalBookPublish, + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + return await InvestmentGoalService().publish_book(goal_no, payload, context, key) diff --git a/app/api/schemas/investment_goals.py b/app/api/schemas/investment_goals.py new file mode 100644 index 0000000..7cd64b1 --- /dev/null +++ b/app/api/schemas/investment_goals.py @@ -0,0 +1,15 @@ +"""HTTP DTOs for investment-goal collection and goal-book review.""" + +from app.core.investment_goal_contracts import ( + InvestmentGoalBookPublish, + InvestmentGoalBookReview, + InvestmentGoalConfirmation, + InvestmentGoalCreate, +) + +__all__ = [ + "InvestmentGoalBookPublish", + "InvestmentGoalBookReview", + "InvestmentGoalConfirmation", + "InvestmentGoalCreate", +] diff --git a/app/core/investment_goal_contracts.py b/app/core/investment_goal_contracts.py new file mode 100644 index 0000000..1fe1dc4 --- /dev/null +++ b/app/core/investment_goal_contracts.py @@ -0,0 +1,75 @@ +"""Contracts for structured investment-goal collection.""" + +from decimal import Decimal +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +LiquidityRequirement = Literal[ + "daily", + "within_7_days", + "within_30_days", + "over_30_days", +] + + +class InvestmentGoalCreate(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + customer_id: int | None = Field(default=None, gt=0) + annualized_return_lower_pct: Decimal = Field(ge=0, le=100, max_digits=7, decimal_places=4) + annualized_return_upper_pct: Decimal = Field(ge=0, le=100, max_digits=7, decimal_places=4) + max_drawdown_pct: Decimal = Field(ge=0, le=100, max_digits=7, decimal_places=4) + liquidity_requirement: LiquidityRequirement + investment_horizon_months: int = Field(ge=1, le=600) + benchmark_name: str = Field(min_length=1, max_length=128) + notes: str | None = Field(default=None, max_length=1000) + + @field_validator("benchmark_name", "notes") + @classmethod + def normalize_text(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("text must not be blank") + if any(ord(char) < 32 and char not in "\n\t" for char in normalized): + raise ValueError("text must not contain control characters") + return normalized + + @model_validator(mode="after") + def validate_return_range(self) -> "InvestmentGoalCreate": + if self.annualized_return_lower_pct > self.annualized_return_upper_pct: + raise ValueError("annualized return lower bound must not exceed upper bound") + return self + + +class InvestmentGoalConfirmation(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + confirmed: Literal[True] + + +class InvestmentGoalBookReview(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + decision: Literal["approved", "rejected"] + comment: str | None = Field(default=None, max_length=1000) + + @field_validator("comment") + @classmethod + def normalize_comment(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized or None + + +class InvestmentGoalBookPublish(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + publish: Literal[True] + + +class InvestmentGoalQuery(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) diff --git a/app/main.py b/app/main.py index 94d932a..bfb7943 100644 --- a/app/main.py +++ b/app/main.py @@ -5,6 +5,7 @@ from app.api.controllers.admin import router as admin_router from app.api.controllers.agent_runs import router as agent_runs_router from app.api.controllers.conversations import router as conversations_router from app.api.controllers.health import router as health_router +from app.api.controllers.investment_goals import router as investment_goals_router from app.api.controllers.knowledge import router as knowledge_router from app.api.controllers.onboarding import router as onboarding_router from app.api.controllers.public_platform import router as public_platform_router @@ -47,6 +48,7 @@ def create_app() -> FastAPI: application.include_router(knowledge_router) application.include_router(health_router) application.include_router(onboarding_router) + application.include_router(investment_goals_router) application.include_router(admin_router) return application diff --git a/app/model/investment_goal.py b/app/model/investment_goal.py new file mode 100644 index 0000000..0e26db4 --- /dev/null +++ b/app/model/investment_goal.py @@ -0,0 +1,53 @@ +"""Persistence models for goals and their client-facing goal books.""" + +from datetime import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import JSON, BigInteger, DateTime, Integer, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.model.base import Base + + +class ClientFacingContent(Base): + """Unchanged baseline content-review resource used by goal books.""" + + __tablename__ = "client_facing_content" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + content_type: Mapped[str] = mapped_column(String(32), nullable=False) + draft_content: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + generated_by_portal: Mapped[str] = mapped_column(String(32), nullable=False) + review_status: Mapped[str] = mapped_column(String(16), nullable=False) + reviewer_user_id: Mapped[int | None] = mapped_column(BigInteger) + reviewed_at: Mapped[datetime | None] = mapped_column(DateTime) + published_at: Mapped[datetime | None] = mapped_column(DateTime) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorInvestmentGoal(Base): + """Collected goal version; only a confirmed version feeds advisory tools.""" + + __tablename__ = "advisor_investment_goal" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + goal_no: Mapped[str] = mapped_column(String(36), unique=True, nullable=False) + customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + status: Mapped[str] = mapped_column(String(24), nullable=False) + annualized_return_lower_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False) + annualized_return_upper_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False) + max_drawdown_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False) + liquidity_requirement: Mapped[str] = mapped_column(String(32), nullable=False) + investment_horizon_months: Mapped[int] = mapped_column(Integer, nullable=False) + benchmark_name: Mapped[str] = mapped_column(String(128), nullable=False) + notes: Mapped[str | None] = mapped_column(Text) + source: Mapped[str] = mapped_column(String(16), nullable=False) + goal_book_content_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True) + created_by: Mapped[int] = mapped_column(BigInteger, nullable=False) + confirmed_by: Mapped[int | None] = mapped_column(BigInteger) + confirmed_at: Mapped[datetime | None] = mapped_column(DateTime) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) diff --git a/app/repository/investment_goal_repository.py b/app/repository/investment_goal_repository.py new file mode 100644 index 0000000..7647f4d --- /dev/null +++ b/app/repository/investment_goal_repository.py @@ -0,0 +1,53 @@ +"""Repository for investment goals and reviewed goal-book content.""" + +from datetime import datetime +from typing import cast + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent + + +class InvestmentGoalRepository: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + def add_goal(self, goal: AdvisorInvestmentGoal) -> None: + self.session.add(goal) + + def add_goal_book(self, content: ClientFacingContent) -> None: + self.session.add(content) + + async def goal(self, goal_no: str, *, lock: bool = False) -> AdvisorInvestmentGoal | None: + query = select(AdvisorInvestmentGoal).where(AdvisorInvestmentGoal.goal_no == goal_no) + if lock: + query = query.with_for_update() + return cast(AdvisorInvestmentGoal | None, await self.session.scalar(query)) + + async def latest_for_customer(self, customer_id: int) -> AdvisorInvestmentGoal | None: + return cast(AdvisorInvestmentGoal | None, await self.session.scalar( + select(AdvisorInvestmentGoal) + .where( + AdvisorInvestmentGoal.customer_id == customer_id, + AdvisorInvestmentGoal.status != "superseded", + ) + .order_by(AdvisorInvestmentGoal.created_at.desc(), AdvisorInvestmentGoal.id.desc()) + .limit(1) + )) + + async def goal_book(self, content_id: int) -> ClientFacingContent | None: + return await self.session.get(ClientFacingContent, content_id) + + async def supersede_confirmed( + self, customer_id: int, except_goal_no: str, now: datetime + ) -> None: + await self.session.execute( + update(AdvisorInvestmentGoal) + .where( + AdvisorInvestmentGoal.customer_id == customer_id, + AdvisorInvestmentGoal.status == "confirmed", + AdvisorInvestmentGoal.goal_no != except_goal_no, + ) + .values(status="superseded", updated_at=now) + ) diff --git a/app/repository/platform_repository.py b/app/repository/platform_repository.py index ef891d6..068a365 100644 --- a/app/repository/platform_repository.py +++ b/app/repository/platform_repository.py @@ -20,6 +20,7 @@ TABLES = frozenset( "svc_handover_ticket", "fin_knowledge_meta", "profile_snapshots", + "client_facing_content", } ) diff --git a/app/service/agent/bootstrap.py b/app/service/agent/bootstrap.py index 9625489..ee96261 100644 --- a/app/service/agent/bootstrap.py +++ b/app/service/agent/bootstrap.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import get_settings from app.core.errors import RecoverableAgentError from app.core.fund_contracts import FundQuoteQuery +from app.core.investment_goal_contracts import InvestmentGoalQuery from app.infrastructure.fund_quote_cache import FundQuoteCache from app.infrastructure.memory_cache import MemoryCacheAdapter from app.infrastructure.vector_memory import VectorMemoryAdapter @@ -16,6 +17,7 @@ from app.service.agent.implementations.advisor import AdvisorAgent from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent from app.service.fund_quote_service import query_fund_quote_tool from app.service.intent_classifier import IntentClassifier +from app.service.investment_goal_service import investment_goal_query_tool from app.service.memory_recall_service import MemoryRecallService from app.service.model_gateway import ( DatabaseModelEndpointResolver, @@ -144,6 +146,13 @@ def get_agent_factory() -> AgentFactory: # (12s)与重试预算,多代码查询必然先撞工具超时。 timeout_seconds=15, )) + registry.register(ToolDefinition( + name="query_investment_goal", + input_model=InvestmentGoalQuery, + handler=cast(Any, investment_goal_query_tool), + required_permission="investment-goal:read:self", + allowed_roles=("customer", "advisor", "operator", "admin"), + )) model_service = get_model_service() endpoint_resolver = DatabaseModelEndpointResolver() factory = AgentFactory( diff --git a/app/service/agent/implementations/advisor.py b/app/service/agent/implementations/advisor.py index da7b95e..75f5f29 100644 --- a/app/service/agent/implementations/advisor.py +++ b/app/service/agent/implementations/advisor.py @@ -1,10 +1,8 @@ -"""投顾 Agent 的迁移骨架。 +"""投顾 Agent 的公共底座实现。""" -当前阶段只接入新底座已有的场内基金行情只读能力;后续业务模块会按迁移 TODO -逐项扩展 definition、工具白名单和处理逻辑。 -""" +from typing import Any -from app.core.contracts import AgentDefinition +from app.core.contracts import AgentDefinition, AgentRequest, CoreResult, RequestContext from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent @@ -16,6 +14,29 @@ class AdvisorAgent(FundQueryDemoAgent): version="0.1.0", allowed_roles=("customer", "advisor", "operator", "admin"), allowed_portals=("api",), - allowed_tools=("query_fund_quote",), - supported_intents=("fund_quote",), + allowed_tools=("query_fund_quote", "query_investment_goal"), + supported_intents=("fund_quote", "investment_goal"), ) + + async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult: + if ( + self._classified_intent is not None + and self._classified_intent.intent == "investment_goal" + ): + output = await self.call_tool( + "query_investment_goal", {}, intent="investment_goal", context=context + ) + if not isinstance(output, dict): + return CoreResult(text="当前没有已确认的投资目标,暂不能用于配置或产品推荐。") + return CoreResult(text=self._describe_goal(output)) + return await super().handle(request, context) + + @staticmethod + def _describe_goal(goal: dict[str, Any]) -> str: + return ( + f"当前投资目标:年化收益目标 {goal['annualized_return_lower_pct']}%-" + f"{goal['annualized_return_upper_pct']}%,最大回撤 {goal['max_drawdown_pct']}%," + f"流动性要求 {goal['liquidity_requirement']},投资期限 " + f"{goal['investment_horizon_months']} 个月,业绩比较基准 {goal['benchmark_name']}。" + "以上为目标采集结果,不构成收益承诺或交易指令。" + ) diff --git a/app/service/investment_goal_service.py b/app/service/investment_goal_service.py new file mode 100644 index 0000000..09d145b --- /dev/null +++ b/app/service/investment_goal_service.py @@ -0,0 +1,373 @@ +"""Application service for investment-goal collection and goal-book workflow.""" + +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.contracts import RequestContext +from app.core.errors import ( + GenericResourceNotFoundError, + InvalidStateError, + ResourceAlreadyExistsError, + ValidationAgentError, +) +from app.core.investment_goal_contracts import ( + InvestmentGoalBookPublish, + InvestmentGoalBookReview, + InvestmentGoalCreate, + InvestmentGoalQuery, +) +from app.infrastructure.db import SessionFactory +from app.model.audit import InteractionAudit +from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent +from app.repository.investment_goal_repository import InvestmentGoalRepository +from app.service.api_transaction_service import ApiTransactionService +from app.service.authorization_service import AuthorizationService + +_LIQUIDITY_LABELS = { + "daily": "可随时使用", + "within_7_days": "7 日内可使用", + "within_30_days": "30 日内可使用", + "over_30_days": "30 日后可使用", +} +_PROHIBITED_GOAL_PHRASES = ("保本", "保证收益", "稳赚", "无风险", "收益承诺") + + +class InvestmentGoalService: + async def create( + self, payload: InvestmentGoalCreate, context: RequestContext, key: str | None + ) -> dict[str, object]: + customer_id = await self._resolve_customer(payload.customer_id, context, "write") + self._validate_notes(payload.notes) + + async def operation(session: AsyncSession) -> dict[str, Any]: + now = _utc_now() + goal_no = f"IG-{uuid4().hex[:24]}" + repository = InvestmentGoalRepository(session) + content = ClientFacingContent( + customer_id=customer_id, + content_type="investment_goal_book", + draft_content=self._goal_book(goal_no, payload), + generated_by_portal=context.portal, + review_status="pending", + reviewer_user_id=None, + reviewed_at=None, + published_at=None, + created_at=now, + updated_at=now, + ) + repository.add_goal_book(content) + await session.flush() + goal = AdvisorInvestmentGoal( + goal_no=goal_no, + customer_id=customer_id, + status="pending_confirmation", + annualized_return_lower_pct=payload.annualized_return_lower_pct, + annualized_return_upper_pct=payload.annualized_return_upper_pct, + max_drawdown_pct=payload.max_drawdown_pct, + liquidity_requirement=payload.liquidity_requirement, + investment_horizon_months=payload.investment_horizon_months, + benchmark_name=payload.benchmark_name, + notes=payload.notes, + source="customer" if customer_id == int(context.user_id) else "advisor", + goal_book_content_id=content.id, + created_by=int(context.user_id), + confirmed_by=None, + confirmed_at=None, + created_at=now, + updated_at=now, + ) + repository.add_goal(goal) + self._audit(session, context, customer_id, "advisor.investment_goal_collected", { + "goal_no": goal_no, + "status": goal.status, + "goal_book_content_id": content.id, + }) + return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}} + + try: + return await ApiTransactionService().execute( + context, + f"advisor:investment-goals:{customer_id}", + key, + payload.model_dump(mode="json"), + operation, + ) + except IntegrityError as exc: + raise ResourceAlreadyExistsError("投资目标创建冲突") from exc + + async def confirm( + self, goal_no: str, context: RequestContext, key: str | None + ) -> dict[str, object]: + async def operation(session: AsyncSession) -> dict[str, Any]: + repository = InvestmentGoalRepository(session) + goal = await repository.goal(goal_no, lock=True) + if goal is None: + raise GenericResourceNotFoundError("投资目标不存在") + await self._assert_customer_access(goal.customer_id, context, "confirm") + if goal.status != "pending_confirmation": + raise InvalidStateError("当前投资目标不能确认") + now = _utc_now() + goal.status = "confirmed" + goal.confirmed_by = int(context.user_id) + goal.confirmed_at = now + goal.updated_at = now + await repository.supersede_confirmed(goal.customer_id, goal.goal_no, now) + content = await repository.goal_book(goal.goal_book_content_id) + if content is None: + raise GenericResourceNotFoundError("投资目标书草稿不存在") + self._audit(session, context, goal.customer_id, "advisor.investment_goal_confirmed", { + "goal_no": goal.goal_no, + "goal_book_content_id": goal.goal_book_content_id, + "review_status": content.review_status, + }) + return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}} + + return await ApiTransactionService().execute( + context, + f"advisor:investment-goals:{goal_no}:confirmation", + key, + {"goal_no": goal_no, "confirmed": True}, + operation, + ) + + async def current(self, customer_id: int, context: RequestContext) -> dict[str, object]: + await self._assert_customer_access(customer_id, context, "read") + async with SessionFactory() as session: + repository = InvestmentGoalRepository(session) + goal = await repository.latest_for_customer(customer_id) + if goal is None: + raise GenericResourceNotFoundError("当前投资目标不存在") + content = await repository.goal_book(goal.goal_book_content_id) + if content is None: + raise GenericResourceNotFoundError("投资目标书草稿不存在") + return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}} + + async def goal_book(self, goal_no: str, context: RequestContext) -> dict[str, object]: + async with SessionFactory() as session: + repository = InvestmentGoalRepository(session) + goal = await repository.goal(goal_no) + if goal is None: + raise GenericResourceNotFoundError("投资目标不存在") + await self._assert_customer_access(goal.customer_id, context, "read") + content = await repository.goal_book(goal.goal_book_content_id) + if content is None: + raise GenericResourceNotFoundError("投资目标书草稿不存在") + return { + "data": { + "goal_no": goal.goal_no, + "goal_status": goal.status, + "review_status": content.review_status, + "content": content.draft_content, + "published_at": _timestamp(content.published_at), + }, + "meta": {"trace_id": context.trace_id}, + } + + async def review_book( + self, goal_no: str, payload: InvestmentGoalBookReview, context: RequestContext, + key: str | None, + ) -> dict[str, object]: + await AuthorizationService.require(context, "investment-goal:review", admin=True) + + async def operation(session: AsyncSession) -> dict[str, Any]: + repository = InvestmentGoalRepository(session) + goal = await repository.goal(goal_no, lock=True) + if goal is None: + raise GenericResourceNotFoundError("投资目标不存在") + content = await repository.goal_book(goal.goal_book_content_id) + if content is None: + raise GenericResourceNotFoundError("投资目标书草稿不存在") + if content.review_status not in {"pending", "approved"}: + raise InvalidStateError("目标书当前不能审核") + now = _utc_now() + if payload.decision == "approved": + content.review_status = "approved" + content.reviewer_user_id = int(context.user_id) + content.reviewed_at = now + else: + content.review_status = "pending" + content.reviewer_user_id = None + content.reviewed_at = None + content.updated_at = now + self._audit( + session, context, goal.customer_id, "advisor.investment_goal_book_reviewed", + { + "goal_no": goal.goal_no, + "content_id": content.id, + "decision": payload.decision, + "comment": payload.comment, + }, + ) + return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}} + + return await ApiTransactionService().execute( + context, f"advisor:investment-goals:{goal_no}:book-review", key, + payload.model_dump(mode="json"), operation, + ) + + async def publish_book( + self, goal_no: str, payload: InvestmentGoalBookPublish, context: RequestContext, + key: str | None, + ) -> dict[str, object]: + await AuthorizationService.require(context, "investment-goal:publish", admin=True) + + async def operation(session: AsyncSession) -> dict[str, Any]: + repository = InvestmentGoalRepository(session) + goal = await repository.goal(goal_no, lock=True) + if goal is None: + raise GenericResourceNotFoundError("投资目标不存在") + content = await repository.goal_book(goal.goal_book_content_id) + if content is None: + raise GenericResourceNotFoundError("投资目标书不存在") + if content.review_status != "approved": + raise InvalidStateError("目标书审核通过后才能发布") + now = _utc_now() + content.review_status = "published" + content.published_at = now + content.updated_at = now + self._audit( + session, context, goal.customer_id, "advisor.investment_goal_book_published", + {"goal_no": goal.goal_no, "content_id": content.id}, + ) + return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}} + + return await ApiTransactionService().execute( + context, f"advisor:investment-goals:{goal_no}:book-publish", key, + payload.model_dump(mode="json"), operation, + ) + + async def current_for_agent(self, context: RequestContext) -> dict[str, object] | None: + await AuthorizationService.require(context, "investment-goal:read:self") + async with SessionFactory() as session: + repository = InvestmentGoalRepository(session) + goal = await repository.latest_for_customer(int(context.user_id)) + if goal is None or goal.status != "confirmed": + return None + content = await repository.goal_book(goal.goal_book_content_id) + return { + "goal_no": goal.goal_no, + "status": goal.status, + "annualized_return_lower_pct": str(goal.annualized_return_lower_pct), + "annualized_return_upper_pct": str(goal.annualized_return_upper_pct), + "max_drawdown_pct": str(goal.max_drawdown_pct), + "liquidity_requirement": goal.liquidity_requirement, + "investment_horizon_months": goal.investment_horizon_months, + "benchmark_name": goal.benchmark_name, + "goal_book_review_status": content.review_status if content else None, + } + + async def _resolve_customer( + self, requested_customer_id: int | None, context: RequestContext, action: str + ) -> int: + customer_id = requested_customer_id or int(context.user_id) + await self._assert_customer_access(customer_id, context, action) + return customer_id + + async def _assert_customer_access( + self, customer_id: int, context: RequestContext, action: str + ) -> None: + own = customer_id == int(context.user_id) + permission = ( + f"investment-goal:{action}:self" + if own + else f"investment-goal:{action}:customer" + ) + await AuthorizationService.require(context, permission) + if own: + return + scope = context.permission_scopes.get(permission, "self") + if scope != "all" and ( + scope != "own_customers" or str(customer_id) not in context.customer_ids + ): + raise GenericResourceNotFoundError("客户不可访问") + + @staticmethod + def _validate_notes(notes: str | None) -> None: + if notes and any(phrase in notes for phrase in _PROHIBITED_GOAL_PHRASES): + raise ValidationAgentError("投资目标说明不得包含收益承诺或保本表述") + + @staticmethod + def _goal_book(goal_no: str, payload: InvestmentGoalCreate) -> dict[str, object]: + return { + "document_type": "investment_goal_book", + "document_version": "1.0", + "goal_no": goal_no, + "sections": { + "investment_objective": { + "annualized_return_expectation_pct": { + "lower": str(payload.annualized_return_lower_pct), + "upper": str(payload.annualized_return_upper_pct), + }, + "benchmark_name": payload.benchmark_name, + }, + "risk_boundary": {"maximum_drawdown_pct": str(payload.max_drawdown_pct)}, + "liquidity": { + "requirement": payload.liquidity_requirement, + "description": _LIQUIDITY_LABELS[payload.liquidity_requirement], + }, + "investment_horizon": {"months": payload.investment_horizon_months}, + "notes": payload.notes, + }, + "disclosures": [ + "收益目标为客户期望与业绩比较基准口径,不构成收益承诺或保证。", + "基金投资有风险,过往业绩不预示未来表现。", + "本目标书为待审核草稿,仅作为后续场内基金模拟交易分析的输入,不构成交易指令。", + ], + } + + @classmethod + def _view(cls, goal: AdvisorInvestmentGoal, content: ClientFacingContent) -> dict[str, object]: + return { + "goal_no": goal.goal_no, + "customer_id": str(goal.customer_id), + "status": goal.status, + "goal_gap_status": ( + "awaiting_confirmation" if goal.status == "pending_confirmation" else "none" + ), + "annualized_return_lower_pct": str(goal.annualized_return_lower_pct), + "annualized_return_upper_pct": str(goal.annualized_return_upper_pct), + "max_drawdown_pct": str(goal.max_drawdown_pct), + "liquidity_requirement": goal.liquidity_requirement, + "investment_horizon_months": goal.investment_horizon_months, + "benchmark_name": goal.benchmark_name, + "notes": goal.notes, + "source": goal.source, + "goal_book": { + "content_id": str(content.id), + "review_status": content.review_status, + "published_at": _timestamp(content.published_at), + }, + "confirmed_at": _timestamp(goal.confirmed_at), + "created_at": _timestamp(goal.created_at), + "updated_at": _timestamp(goal.updated_at), + } + + @staticmethod + def _audit( + session: AsyncSession, context: RequestContext, customer_id: int, + action_type: str, detail: dict[str, object], + ) -> None: + session.add(InteractionAudit( + actor_type="user", actor_id=int(context.user_id), target_customer_id=customer_id, + portal=context.portal, action_type=action_type, + detail={**detail, "trace_id": context.trace_id}, created_at=_utc_now(), + )) + + +def _utc_now() -> datetime: + return datetime.now(UTC).replace(tzinfo=None) + + +def _timestamp(value: datetime | None) -> str | None: + return value.isoformat() + "Z" if value is not None else None + + +async def investment_goal_query_tool( + _arguments: InvestmentGoalQuery, context: RequestContext +) -> dict[str, object] | None: + """Read-only Agent entry point; only confirmed goals are exposed to the Agent.""" + return await InvestmentGoalService().current_for_agent(context) diff --git a/docs/21-投顾Agent迁移TODO.md b/docs/21-投顾Agent迁移TODO.md index aea405c..67653b5 100644 --- a/docs/21-投顾Agent迁移TODO.md +++ b/docs/21-投顾Agent迁移TODO.md @@ -62,7 +62,19 @@ R4=7、R5=1;资产分类成功 18 个,1 个因合同证据不足跳过。每 通过,MyPy(116 个源文件)通过。数据库审计工具已执行,但默认 `.env` 连接的是旧 `jr_agent` 库,结构审计发现旧库的场外表和基线约束差异;阶段四已验证独立库 `jr_agent_qyqy_migration` 的迁移结构,不能将旧库结果作为新底座验收结果。 -阶段六提交:待提交。 +阶段六提交:`acb9175`。 + +### 阶段七:投资目标和目标书 + +已完成投资目标结构化采集、目标书草稿生成、客户确认、投顾审核和发布闭环。目标 +创建后状态为 `pending_confirmation`,目标书使用基线 `client_facing_content` 的 +`pending -> approved -> published` 审核状态;未经审核不能发布。目标查询工具只向 +Agent 暴露客户最新的 `confirmed` 目标,未确认目标不会进入后续推荐和配置输入。 +目标收益范围、最大回撤、流动性要求、期限、业绩比较基准和备注均有服务端校验, +目标书含收益非承诺、非交易指令披露;写操作统一使用 `Idempotency-Key` 并写入审计。 + +阶段七测试结果:专项测试 `5 passed`,全量单元测试 `468 passed, 3 warnings`,Ruff +通过,MyPy(122 个源文件)通过。阶段七提交:待提交。 ## 一、迁移准备 @@ -220,23 +232,23 @@ python tools/audit_constraints.py ## 七、投资目标和目标书 -- [ ] 迁移投资目标创建接口。 -- [ ] 迁移收益目标下限和上限。 -- [ ] 迁移最大回撤字段。 -- [ ] 迁移流动性要求字段。 -- [ ] 迁移投资期限字段。 -- [ ] 迁移业绩比较基准字段。 -- [ ] 迁移目标备注字段。 -- [ ] 迁移目标书生成。 -- [ ] 迁移客户确认流程。 -- [ ] 迁移投顾审核流程。 -- [ ] 迁移当前目标查询工具。 -- [ ] 迁移目标缺口状态。 -- [ ] 确认目标创建后为 `pending_confirmation`。 -- [ ] 确认未确认目标不能用于推荐和配置。 -- [ ] 确认目标书未审核不能对客发布。 -- [ ] 确认收益目标不被表述为收益承诺。 -- [ ] 完成投资目标提交 `advisor/investment-goal`。 +- [x] 迁移投资目标创建接口。 +- [x] 迁移收益目标下限和上限。 +- [x] 迁移最大回撤字段。 +- [x] 迁移流动性要求字段。 +- [x] 迁移投资期限字段。 +- [x] 迁移业绩比较基准字段。 +- [x] 迁移目标备注字段。 +- [x] 迁移目标书生成。 +- [x] 迁移客户确认流程。 +- [x] 迁移投顾审核流程。 +- [x] 迁移当前目标查询工具。 +- [x] 迁移目标缺口状态。 +- [x] 确认目标创建后为 `pending_confirmation`。 +- [x] 确认未确认目标不能用于推荐和配置。 +- [x] 确认目标书未审核不能对客发布。 +- [x] 确认收益目标不被表述为收益承诺。 +- [x] 完成投资目标提交 `advisor/investment-goal`。(专项 `5 passed`;全量单元 `468 passed`) ## 八、持仓分析 diff --git a/tests/unit/service/test_advisor_base_adapter.py b/tests/unit/service/test_advisor_base_adapter.py index 59832e2..617ea2c 100644 --- a/tests/unit/service/test_advisor_base_adapter.py +++ b/tests/unit/service/test_advisor_base_adapter.py @@ -18,5 +18,5 @@ def test_advisor_is_registered_through_the_new_base_factory() -> None: assert isinstance(agent, BaseAgent) assert isinstance(agent, AdvisorAgent) assert agent.definition == definition - assert definition.allowed_tools == ("query_fund_quote",) - assert definition.supported_intents == ("fund_quote",) + assert definition.allowed_tools == ("query_fund_quote", "query_investment_goal") + assert definition.supported_intents == ("fund_quote", "investment_goal") diff --git a/tests/unit/service/test_investment_goal_service.py b/tests/unit/service/test_investment_goal_service.py new file mode 100644 index 0000000..189d93e --- /dev/null +++ b/tests/unit/service/test_investment_goal_service.py @@ -0,0 +1,178 @@ +from datetime import datetime +from decimal import Decimal +from typing import Any, cast + +import pytest +from pydantic import ValidationError + +from app.core.contracts import RequestContext +from app.core.errors import InvalidStateError, ValidationAgentError +from app.core.investment_goal_contracts import ( + InvestmentGoalBookPublish, + InvestmentGoalBookReview, + InvestmentGoalCreate, +) +from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent +from app.service.api_transaction_service import ApiTransactionService +from app.service.investment_goal_service import InvestmentGoalService + + +def context(*permissions: str, roles: tuple[str, ...] = ("customer",)) -> RequestContext: + return RequestContext(user_id="7", trace_id="goal-test", roles=roles, permissions=permissions) + + +def payload(**overrides: object) -> InvestmentGoalCreate: + values: dict[str, object] = { + "annualized_return_lower_pct": "4.0", + "annualized_return_upper_pct": "6.0", + "max_drawdown_pct": "8.0", + "liquidity_requirement": "within_30_days", + "investment_horizon_months": 36, + "benchmark_name": "中证全指收益率", + } + values.update(overrides) + return InvestmentGoalCreate.model_validate(values) + + +class FakeSession: + def __init__(self, goal: AdvisorInvestmentGoal | None = None) -> None: + self.goal = goal + self.content: ClientFacingContent | None = None + self.items: list[object] = [] + self.executed: list[object] = [] + + def add(self, item: object) -> None: + self.items.append(item) + if isinstance(item, ClientFacingContent): + self.content = item + + async def flush(self) -> None: + assert self.content is not None + self.content.id = 101 + + async def scalar(self, _statement: object) -> AdvisorInvestmentGoal | None: + return self.goal + + async def get(self, _model: object, _identity: int) -> ClientFacingContent | None: + return self.content + + async def execute(self, statement: object) -> None: + self.executed.append(statement) + + +async def run_operation( + monkeypatch: pytest.MonkeyPatch, session: FakeSession, method: str, *args: object +) -> dict[str, Any]: + async def execute( + _self: ApiTransactionService, + _context: RequestContext, + _scope: str, + _key: str | None, + _body: Any, + operation: Any, + ) -> dict[str, Any]: + return cast(dict[str, Any], await operation(session)) + + monkeypatch.setattr(ApiTransactionService, "execute", execute) + result = await getattr(InvestmentGoalService(), method)(*args) + return cast(dict[str, Any], result) + + +@pytest.mark.asyncio +async def test_create_writes_pending_goal_and_reviewable_book( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeSession() + result = await run_operation( + monkeypatch, session, "create", payload(), context("investment-goal:write:self"), + "goal-create-key-0001", + ) + goal = next(item for item in session.items if isinstance(item, AdvisorInvestmentGoal)) + assert goal.status == "pending_confirmation" + assert session.content is not None + assert session.content.review_status == "pending" + assert cast(dict[str, Any], result["data"])["goal_gap_status"] == "awaiting_confirmation" + + +@pytest.mark.asyncio +async def test_confirmation_activates_goal_but_does_not_publish_book( + monkeypatch: pytest.MonkeyPatch, +) -> None: + created_at = datetime(2026, 9, 10) + goal = AdvisorInvestmentGoal( + id=1, goal_no="IG-confirmation-test", customer_id=7, status="pending_confirmation", + annualized_return_lower_pct=Decimal("4"), annualized_return_upper_pct=Decimal("6"), + max_drawdown_pct=Decimal("8"), liquidity_requirement="daily", investment_horizon_months=24, + benchmark_name="中证全指收益率", notes=None, source="customer", goal_book_content_id=101, + created_by=7, confirmed_by=None, confirmed_at=None, + created_at=created_at, updated_at=created_at, + ) + session = FakeSession(goal) + session.content = ClientFacingContent( + id=101, customer_id=7, content_type="investment_goal_book", draft_content={}, + generated_by_portal="api", review_status="pending", reviewer_user_id=None, + reviewed_at=None, published_at=None, created_at=created_at, updated_at=created_at, + ) + result = await run_operation( + monkeypatch, session, "confirm", goal.goal_no, + context("investment-goal:confirm:self"), "goal-confirm-key-0001", + ) + assert goal.status == "confirmed" + assert session.content.published_at is None + assert cast(dict[str, Any], result["data"])["goal_book"]["review_status"] == "pending" + + +@pytest.mark.asyncio +async def test_review_then_publish_requires_approved_book( + monkeypatch: pytest.MonkeyPatch, +) -> None: + created_at = datetime(2026, 9, 10) + goal = AdvisorInvestmentGoal( + id=1, goal_no="IG-review-test", customer_id=7, status="confirmed", + annualized_return_lower_pct=Decimal("4"), annualized_return_upper_pct=Decimal("6"), + max_drawdown_pct=Decimal("8"), liquidity_requirement="daily", investment_horizon_months=24, + benchmark_name="中证全指收益率", notes=None, source="customer", goal_book_content_id=101, + created_by=7, confirmed_by=7, confirmed_at=created_at, + created_at=created_at, updated_at=created_at, + ) + session = FakeSession(goal) + session.content = ClientFacingContent( + id=101, customer_id=7, content_type="investment_goal_book", draft_content={}, + generated_by_portal="api", review_status="pending", reviewer_user_id=None, + reviewed_at=None, published_at=None, created_at=created_at, updated_at=created_at, + ) + + async def allow(*_args: object, **_kwargs: object) -> None: + return None + + monkeypatch.setattr("app.service.investment_goal_service.AuthorizationService.require", allow) + with pytest.raises(InvalidStateError, match="审核通过"): + await run_operation( + monkeypatch, session, "publish_book", goal.goal_no, + InvestmentGoalBookPublish(publish=True), + context("investment-goal:publish", roles=("admin",)), "goal-publish-key-0000", + ) + await run_operation( + monkeypatch, session, "review_book", goal.goal_no, + InvestmentGoalBookReview(decision="approved"), + context("investment-goal:review", roles=("admin",)), "goal-review-key-0001", + ) + assert session.content.review_status == "approved" + await run_operation( + monkeypatch, session, "publish_book", goal.goal_no, + InvestmentGoalBookPublish(publish=True), + context("investment-goal:publish", roles=("admin",)), "goal-publish-key-0001", + ) + assert session.content.review_status == "published" + assert session.content.published_at is not None + + +def test_goal_constraints_and_disclosures_are_validated() -> None: + with pytest.raises(ValidationError, match="lower bound"): + payload(annualized_return_lower_pct="8", annualized_return_upper_pct="6") + with pytest.raises(ValidationError): + payload(liquidity_requirement="tomorrow") + with pytest.raises(ValidationAgentError, match="收益承诺"): + InvestmentGoalService._validate_notes("希望保证收益") + book = InvestmentGoalService._goal_book("IG-1", payload()) + assert "不构成收益承诺" in cast(list[str], book["disclosures"])[0] diff --git a/tools/audit_constraints.py b/tools/audit_constraints.py index 1b3f7bf..121eaaf 100644 --- a/tools/audit_constraints.py +++ b/tools/audit_constraints.py @@ -30,6 +30,7 @@ from app.model import ( # noqa: E402,F401 configuration, conversation, fund, + investment_goal, memory, platform, session,