feat: migrate advisor investment goals

This commit is contained in:
Windows
2026-09-11 13:11:58 +08:00
parent acb9175e31
commit 60c4a49b9b
14 changed files with 903 additions and 27 deletions
+83
View File
@@ -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)
+15
View File
@@ -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",
]
+75
View File
@@ -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)
+2
View File
@@ -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
+53
View File
@@ -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)
@@ -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)
)
+1
View File
@@ -20,6 +20,7 @@ TABLES = frozenset(
"svc_handover_ticket",
"fin_knowledge_meta",
"profile_snapshots",
"client_facing_content",
}
)
+9
View File
@@ -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(
+28 -7
View File
@@ -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']}。"
"以上为目标采集结果,不构成收益承诺或交易指令。"
)
+373
View File
@@ -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)