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)
+30 -18
View File
@@ -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`)
## 八、持仓分析
@@ -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")
@@ -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]
+1
View File
@@ -30,6 +30,7 @@ from app.model import ( # noqa: E402,F401
configuration,
conversation,
fund,
investment_goal,
memory,
platform,
session,