feat: add Nailong Fund advisor capabilities

This commit is contained in:
Windows
2026-09-11 09:47:40 +08:00
parent 5907fcd6d2
commit a4499defee
143 changed files with 12255 additions and 89 deletions
+49 -11
View File
@@ -7,50 +7,88 @@ from starlette.responses import StreamingResponse
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
from app.api.dependencies.rate_limit import enforce_rate_limit
from app.api.schemas.agent_runs import (
AgentRunAcceptedEnvelope,
AgentRunAcceptedResponse,
AgentRunCreateRequest,
AgentRunStatusEnvelope,
AgentRunStatusResponse,
)
from app.api.views.agent_run_sse import encode_events, recovery_events
from app.core.config import get_settings
from app.core.contracts import AgentRequest, RequestContext
from app.core.errors import SseNotAcceptableError
from app.service.agent_run_application_service import AgentRunApplicationService
from app.service.run_query_service import RunQueryService
router = APIRouter(prefix="/api/v1/agent-runs", tags=["agent-runs"])
router = APIRouter(
prefix="/api/v1/agent-runs",
tags=["agent-runs"],
dependencies=[Depends(enforce_rate_limit)],
)
@router.post("", response_model=AgentRunAcceptedResponse, status_code=status.HTTP_202_ACCEPTED)
def accepts_event_stream(accept: str | None) -> bool:
if accept is None or not accept.strip():
return True
for entry in accept.split(","):
parts = entry.split(";")
if parts[0].strip().lower() not in {"text/event-stream", "text/*", "*/*"}:
continue
quality = 1.0
for parameter in parts[1:]:
name, _, value = parameter.partition("=")
if name.strip().lower() == "q":
try:
quality = float(value.strip())
except ValueError:
quality = 0.0
if quality > 0:
return True
return False
@router.post("", response_model=AgentRunAcceptedEnvelope, status_code=status.HTTP_202_ACCEPTED)
async def create_agent_run(
payload: AgentRunCreateRequest,
request: Request,
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
) -> AgentRunAcceptedResponse:
) -> AgentRunAcceptedEnvelope:
request.state.request_context = context
accepted = await AgentRunApplicationService(session).accept(
AgentRequest(**payload.model_dump()), context)
return AgentRunAcceptedResponse(
run_id=accepted.run_id, trace_id=accepted.trace_id, status=accepted.status,
status_url=f"/api/v1/agent-runs/{accepted.run_id}",
events_url=f"/api/v1/agent-runs/{accepted.run_id}/events",
return AgentRunAcceptedEnvelope(
data=AgentRunAcceptedResponse(
run_id=accepted.run_id, trace_id=accepted.trace_id, status=accepted.status,
status_url=f"/api/v1/agent-runs/{accepted.run_id}",
events_url=f"/api/v1/agent-runs/{accepted.run_id}/events",
),
meta={"trace_id": context.trace_id},
)
@router.get("/{run_id}", response_model=AgentRunStatusResponse)
@router.get("/{run_id}", response_model=AgentRunStatusEnvelope)
async def get_agent_run(
run_id: str, context: RequestContext = Depends(build_request_context), # noqa: B008
) -> AgentRunStatusResponse:
return AgentRunStatusResponse(**asdict(await RunQueryService().get(run_id, context)))
) -> AgentRunStatusEnvelope:
return AgentRunStatusEnvelope(
data=AgentRunStatusResponse(**asdict(await RunQueryService().get(run_id, context))),
meta={"trace_id": context.trace_id},
)
@router.get("/{run_id}/events")
async def stream_agent_run_events(
run_id: str, context: RequestContext = Depends(build_request_context), # noqa: B008
run_id: str,
request: Request,
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> StreamingResponse:
query = RunQueryService()
initial = await query.get(run_id, context)
if not accepts_event_stream(request.headers.get("Accept")):
raise SseNotAcceptableError("Accept must allow text/event-stream")
async def generate() -> AsyncIterator[str]:
start_sent = False
+9 -2
View File
@@ -3,21 +3,28 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
from app.api.dependencies.rate_limit import enforce_rate_limit
from app.api.schemas.conversations import FeedbackRequest, HandoverRequest
from app.core.contracts import RequestContext
from app.core.cursor import parse_cursor
from app.service.conversation_service import ConversationService
router = APIRouter(prefix="/api/v1", tags=["conversations"])
router = APIRouter(
prefix="/api/v1", tags=["conversations"], dependencies=[Depends(enforce_rate_limit)]
)
@router.get("/conversations/{session_id}/messages")
async def list_messages(
session_id: str,
limit: int = Query(default=20, ge=1, le=100),
cursor: str | None = Query(default=None),
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
) -> dict[str, object]:
return await ConversationService(session).messages(session_id, context, limit)
return await ConversationService(session).messages(
session_id, context, limit, before=parse_cursor(cursor)
)
@router.post("/conversation-messages/{message_id}/feedback", status_code=status.HTTP_201_CREATED)
+55
View File
@@ -0,0 +1,55 @@
"""Advisory investment-goal collection and draft goal-book endpoints."""
from typing import Any
from fastapi import APIRouter, Depends, Header, status
from app.api.dependencies.auth import build_request_context
from app.api.schemas.investment_goals import 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"])
@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]:
del payload
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)
+17
View File
@@ -0,0 +1,17 @@
"""Local-only test access endpoint."""
from fastapi import APIRouter
from app.service.local_test_access_service import LocalTestAccessService
router = APIRouter(prefix="/api/v1/local-test", tags=["local-test"])
@router.post("/access-token")
async def issue_access_token() -> dict[str, object]:
return await LocalTestAccessService().issue_access_token()
@router.post("/admin-access-token")
async def issue_admin_access_token() -> dict[str, object]:
return await LocalTestAccessService().issue_admin_access_token()
+33
View File
@@ -0,0 +1,33 @@
"""Administrative visibility into public market-data source health."""
from typing import Any
from fastapi import APIRouter, Depends, Query
from app.api.dependencies.auth import build_request_context
from app.core.contracts import RequestContext
from app.service.market_data_health_service import MarketDataHealthService
router = APIRouter(prefix="/api/v1/admin/market-data", tags=["market-data"])
@router.get("/health")
async def source_health(
limit: int = Query(default=20, ge=1, le=100),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
return {
"data": await MarketDataHealthService().health(context, limit=limit),
"meta": {"trace_id": context.trace_id},
}
@router.get("/alerts")
async def source_alerts(
limit: int = Query(default=50, ge=1, le=100),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
return {
"data": await MarketDataHealthService().alerts(context, limit=limit),
"meta": {"trace_id": context.trace_id},
}
+26
View File
@@ -0,0 +1,26 @@
"""Mandatory customer onboarding endpoints."""
from fastapi import APIRouter, Depends, Header, status
from app.api.dependencies.auth import build_request_context
from app.api.schemas.risk_questionnaire import RiskQuestionnaireSubmission
from app.core.contracts import RequestContext
from app.service.risk_questionnaire_service import RiskQuestionnaireService
router = APIRouter(prefix="/api/v1/onboarding", tags=["customer-onboarding"])
@router.get("/risk-questionnaire")
async def get_risk_questionnaire(
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, object]:
return await RiskQuestionnaireService().questionnaire(context)
@router.post("/risk-questionnaire/submissions", status_code=status.HTTP_201_CREATED)
async def submit_risk_questionnaire(
payload: RiskQuestionnaireSubmission,
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
return await RiskQuestionnaireService().submit(payload, context, key)
+64
View File
@@ -0,0 +1,64 @@
"""Administrative product-governance monitoring and compliance review APIs."""
from typing import Any
from fastapi import APIRouter, Depends, Query
from app.api.dependencies.auth import build_request_context
from app.api.schemas.product_governance import ProductGovernanceReview
from app.core.contracts import RequestContext
from app.service.product_governance_monitor_service import ProductGovernanceMonitorService
router = APIRouter(prefix="/api/v1/admin/product-governance", tags=["product-governance"])
@router.post("/sync-runs")
async def run_official_source_monitor(
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, object]:
result = await ProductGovernanceMonitorService().run_manually(context)
return {
"data": {
"run_no": result.run_no,
"product_count": result.product_count,
"change_count": result.change_count,
"error_count": result.error_count,
},
"meta": {"trace_id": context.trace_id},
}
@router.get("/sync-runs")
async def list_sync_runs(
limit: int = Query(default=30, ge=1, le=100),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
return {
"data": await ProductGovernanceMonitorService().runs(context, limit=limit),
"meta": {"trace_id": context.trace_id},
}
@router.get("/review-queue")
async def review_queue(
limit: int = Query(default=100, ge=1, le=100),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, Any]:
return {
"data": await ProductGovernanceMonitorService().pending(context, limit=limit),
"meta": {"trace_id": context.trace_id},
}
@router.post("/candidates/{candidate_id}/reviews")
async def review_candidate(
candidate_id: int,
payload: ProductGovernanceReview,
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, object]:
return {
"data": await ProductGovernanceMonitorService().review(
candidate_id, payload.decision, payload.comment, context
),
"meta": {"trace_id": context.trace_id},
}
@@ -0,0 +1,55 @@
"""Reviewed publication workflow for client-facing recommendation plans."""
from fastapi import APIRouter, Depends, Header, Query, status
from app.api.dependencies.auth import build_request_context
from app.api.schemas.recommendation_plans import (
RecommendationPlanGenerate,
RecommendationPlanReview,
)
from app.core.contracts import RequestContext
from app.service.recommendation_plan_service import RecommendationPlanService
router = APIRouter(
prefix="/api/v1/advisor/recommendation-plans",
tags=["advisor-recommendation-plans"],
)
admin_router = APIRouter(
prefix="/api/v1/admin/advisor/recommendation-plans",
tags=["advisor-recommendation-plan-review"],
)
@router.post("", status_code=status.HTTP_201_CREATED)
async def generate_plan(
payload: RecommendationPlanGenerate,
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
return await RecommendationPlanService().generate(payload, context, key)
@router.get("/published")
async def published_plans(
limit: int = Query(default=20, ge=1, le=100),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, object]:
return await RecommendationPlanService().published(context, limit=limit)
@admin_router.get("/review-queue")
async def pending_plans(
limit: int = Query(default=100, ge=1, le=100),
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> dict[str, object]:
return await RecommendationPlanService().pending(context, limit=limit)
@admin_router.post("/{content_id}/reviews")
async def review_plan(
content_id: int,
payload: RecommendationPlanReview,
context: RequestContext = Depends(build_request_context), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
return await RecommendationPlanService().review(content_id, payload, context, key)