diff --git a/TODO.md b/TODO.md index 03adf80..743dd54 100644 --- a/TODO.md +++ b/TODO.md @@ -115,6 +115,16 @@ - [x] 发布业务组员 Agent 接入使用说明书。 新增 `docs/11-业务组员Agent接入使用说明书.md`,明确组员提交物、公共执行顺序、模型/意图/工具/适当性 使用方式、禁止事项和验收命令。截图问题 2 的示例 Agent 仍按要求不处理。 +- [ ] 基金行情数据底座接入(计划已发布)。 + 计划文档:`docs/12-基金行情数据底座接入开发计划.md`。按 F0-F6 先完成不改表的 Adapter、Service、 + 缓存/健康和 `query_fund_quote` 公共只读工具;F7 行情快照表仅在历史回测或监管留痕确有需求时启动。 + 当前进度:F0、F1、F2、F3、F4、F5 已完成;F6 底座侧接入规范已发布并通过工具契约检查,等待业务 Agent + 按清单声明具体意图接入;F7 行情快照表暂不启动。 + 使用说明已同步:`docs/11-业务组员Agent接入使用说明书.md`、`docs/09-底座使用文档.md` 已明确 + `query_fund_quote` 的可用条件、配置键、调用方式和当前未注册业务 Agent 的限制。 +- [x] 合并组员使用说明书。 + 新增 `docs/14-Agent组员统一接入说明书.md`,作为业务组员唯一推荐入口,整合启动、注册、公共执行链、 + 模型、意图、工具、适当性、行情、禁止事项和验收命令;09、11 保留作为详细历史参考。 - [x] 完成 MVC+S 底座设计。 - [x] 完成 BaseAgent、AgentFactory、记忆、Neo4j、配置中心和模型路由设计。 diff --git a/app/core/fund_contracts.py b/app/core/fund_contracts.py new file mode 100644 index 0000000..f304ad8 --- /dev/null +++ b/app/core/fund_contracts.py @@ -0,0 +1,43 @@ +"""基金行情公共契约。内部使用稳定英文字段,不暴露供应商字段名。""" + +from datetime import date, datetime +from decimal import Decimal +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class FundQuoteQuery(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + fund_codes: tuple[str, ...] = () + fund_type: str | None = None + target_date: date | None = None + limit: int = Field(default=50, ge=1, le=1000) + + @field_validator("fund_codes") + @classmethod + def validate_codes(cls, value: tuple[str, ...]) -> tuple[str, ...]: + for code in value: + if len(code) != 6 or not code.isdigit(): + raise ValueError("fund code must be a six-digit number") + return tuple(dict.fromkeys(value)) + + +class FundQuote(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + fund_code: str + fund_name: str + fund_type: str | None = None + nav: Decimal | None = None + nav_date: date | None = None + daily_change: Decimal | None = None + half_year_return: Decimal | None = None + one_year_return: Decimal | None = None + year_to_date_return: Decimal | None = None + since_inception_return: Decimal | None = None + quote_time: datetime + quote_source: Literal["eastmoney", "cache", "degraded"] + is_intraday: bool + degraded: bool = False diff --git a/app/infrastructure/fund_market_adapter.py b/app/infrastructure/fund_market_adapter.py new file mode 100644 index 0000000..eeb1856 --- /dev/null +++ b/app/infrastructure/fund_market_adapter.py @@ -0,0 +1,138 @@ +"""东方财富基金行情适配器。 + +该模块只负责外部 HTTP 调用和供应商响应解析,不依赖 Agent、Controller 或数据库。 +""" + +import asyncio +import re +from datetime import date +from decimal import Decimal, InvalidOperation +from typing import Any, Protocol + +import httpx + +from app.core.errors import RecoverableAgentError + +NAV_API = "https://api.fund.eastmoney.com/f10/lsjz" +RETURN_API = "https://api.fund.eastmoney.com/pinzhong/LJSYLZS" +QUOTE_API = "https://push2.eastmoney.com/api/qt/ulist.np/get" +DETAIL_API = "https://fund.eastmoney.com/pingzhongdata/{code}.js" +HEADERS = {"User-Agent": "Mozilla/5.0", "Referer": "https://fund.eastmoney.com/"} + + +class FundMarketAdapter(Protocol): + async def fetch_names(self, codes: list[str]) -> dict[str, str]: ... + + async def fetch_quotes(self, codes: list[str]) -> dict[str, dict[str, Any]]: ... + + async def fetch_history(self, code: str, target_date: date) -> dict[str, Any]: ... + + +class EastmoneyFundAdapter: + def __init__( + self, + client: httpx.AsyncClient | None = None, + *, + timeout_seconds: float = 12.0, + retries: int = 2, + ) -> None: + self._client = client + self._owns_client = client is None + self.timeout_seconds = timeout_seconds + self.retries = max(0, retries) + + async def fetch_names(self, codes: list[str]) -> dict[str, str]: + result: dict[str, str] = {} + for code in codes: + try: + response = await self._request("GET", DETAIL_API.format(code=code)) + match = re.search(r"var\s+fS_name\s*=\s*[\"']([^\"']+)", response.text) + result[code] = match.group(1).strip() if match else f"基金 {code}" + except RecoverableAgentError: + result[code] = f"基金 {code}" + return result + + async def fetch_quotes(self, codes: list[str]) -> dict[str, dict[str, Any]]: + if not codes: + return {} + secids = ",".join( + ("1." if code.startswith(("5", "6", "9")) else "0.") + code + for code in codes + ) + try: + response = await self._request( + "GET", QUOTE_API, + params={"fltt": 2, "invt": 2, "fields": "f12,f2,f3", "secids": secids}, + ) + items = ((response.json().get("data") or {}).get("diff") or []) + except (RecoverableAgentError, ValueError, TypeError): + return {} + result: dict[str, dict[str, Any]] = {} + for item in items: + code = str(item.get("f12")) if item.get("f12") else "" + if code: + result[code] = { + "nav": self._decimal(item.get("f2")), + "daily_change": self._decimal(item.get("f3")), + } + return result + + async def fetch_history(self, code: str, target_date: date) -> dict[str, Any]: + try: + response = await self._request( + "GET", NAV_API, + params={"fundCode": code, "pageIndex": 1, "pageSize": 30, + "startDate": "", "endDate": ""}, + ) + records = ((response.json().get("Data") or {}).get("LSJZList") or []) + except (RecoverableAgentError, ValueError, TypeError): + return {"degraded": True} + selected = next( + (item for item in records if item.get("FSRQ") == target_date.isoformat()), None + ) + if selected is None and records: + selected = records[0] + if not selected: + return {"degraded": True} + return { + "nav": self._decimal(selected.get("DWJZ")), + "nav_date": self._date(selected.get("FSRQ")), + "daily_change": self._decimal(selected.get("JZZZL")), + "degraded": False, + } + + async def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response: + client = self._client or httpx.AsyncClient(headers=HEADERS) + try: + last_error: Exception | None = None + for attempt in range(self.retries + 1): + try: + response = await client.request( + method, url, timeout=self.timeout_seconds, **kwargs + ) + response.raise_for_status() + return response + except (httpx.HTTPError, TimeoutError) as exc: + last_error = exc + if attempt < self.retries: + await asyncio.sleep(0.2 * (2**attempt)) + raise RecoverableAgentError("基金行情数据源不可用") from last_error + finally: + if self._owns_client: + await client.aclose() + + @staticmethod + def _decimal(value: Any) -> Decimal | None: + if value in (None, "", "-"): + return None + try: + return Decimal(str(value).replace("%", "")) + except (InvalidOperation, ValueError): + return None + + @staticmethod + def _date(value: Any) -> date | None: + try: + return date.fromisoformat(str(value)) + except (TypeError, ValueError): + return None diff --git a/app/infrastructure/fund_quote_cache.py b/app/infrastructure/fund_quote_cache.py new file mode 100644 index 0000000..5ebe323 --- /dev/null +++ b/app/infrastructure/fund_quote_cache.py @@ -0,0 +1,22 @@ +"""基金行情短缓存适配器。缓存失败只影响性能,不阻塞外部行情查询。""" + +from typing import Protocol + +from app.infrastructure.memory_cache import CacheReadResult + + +class FundQuoteCacheClient(Protocol): + async def get(self, key: str) -> CacheReadResult: ... + + async def set(self, key: str, value: str, ttl_seconds: int) -> bool: ... + + +class FundQuoteCache: + def __init__(self, client: FundQuoteCacheClient) -> None: + self.client = client + + async def get(self, key: str) -> CacheReadResult: + return await self.client.get(key) + + async def set(self, key: str, value: str, ttl_seconds: int) -> bool: + return await self.client.set(key, value, ttl_seconds) diff --git a/app/service/agent/bootstrap.py b/app/service/agent/bootstrap.py index b12e56a..e89e86e 100644 --- a/app/service/agent/bootstrap.py +++ b/app/service/agent/bootstrap.py @@ -1,7 +1,9 @@ from functools import lru_cache from typing import Any, cast +from app.core.fund_contracts import FundQuoteQuery from app.service.agent.factory import AgentFactory +from app.service.fund_quote_service import query_fund_quote_tool from app.service.intent_classifier import IntentClassifier from app.service.model_gateway import ( DatabaseModelEndpointResolver, @@ -9,10 +11,7 @@ from app.service.model_gateway import ( ModelDispatchService, ModelGenerationService, ) -from app.service.suitability_service import ( - SuitabilityToolInput, - suitability_tool_handler, -) +from app.service.suitability_service import SuitabilityToolInput, suitability_tool_handler from app.service.tool_executor import ToolDefinition, ToolExecutor, ToolRegistry @@ -27,6 +26,14 @@ def get_agent_factory() -> AgentFactory: required_permission="suitability:read", allowed_roles=("customer", "advisor", "operator", "admin"), )) + registry.register(ToolDefinition( + name="query_fund_quote", + input_model=FundQuoteQuery, + handler=cast(Any, query_fund_quote_tool), + required_permission="fund:quote:read", + allowed_roles=("customer", "advisor", "operator", "risk_operator", "admin"), + timeout_seconds=5, + )) model_service = ModelGenerationService(ModelDispatchService(DatabaseModelGateway())) endpoint_resolver = DatabaseModelEndpointResolver() return AgentFactory( diff --git a/app/service/fund_quote_service.py b/app/service/fund_quote_service.py new file mode 100644 index 0000000..f57f71c --- /dev/null +++ b/app/service/fund_quote_service.py @@ -0,0 +1,209 @@ +"""基金行情业务服务:合并外部实时行情和历史净值,向上层提供稳定结果。""" + +import json +from datetime import UTC, date, datetime, time +from typing import Any, Literal +from zoneinfo import ZoneInfo + +from app.core.contracts import RequestContext +from app.core.fund_contracts import FundQuote, FundQuoteQuery +from app.infrastructure.fund_market_adapter import FundMarketAdapter +from app.infrastructure.fund_quote_cache import FundQuoteCache + +FUND_TYPE_GROUPS: dict[str, tuple[str, ...]] = { + "货币型": ("202308", "020480"), + "债券型": ("007161", "003776", "020281"), + "混合型": ("018019", "014189", "018020"), + "股票型": ( + "020553", "016449", "008854", "008264", "008736", "010592", "160127", + "588890", "020839", "589700", "159382", "159511", "002900", "021958", + "159948", "009059", "001421", + ), +} +SOUTHERN_FUND_CODES: tuple[str, ...] = tuple( + dict.fromkeys(code for codes in FUND_TYPE_GROUPS.values() for code in codes) +) +FUND_TYPE_BY_CODE = { + code: fund_type for fund_type, codes in FUND_TYPE_GROUPS.items() for code in codes +} +SHANGHAI_TZ = ZoneInfo("Asia/Shanghai") + + +class FundQuoteRuntimeConfig: + """行情运行参数;缺失或非法配置时使用安全默认值。""" + + def __init__( + self, + *, + allowed_codes: tuple[str, ...] = SOUTHERN_FUND_CODES, + intraday_cache_ttl: int = 60, + closing_cache_ttl: int = 900, + ) -> None: + self.allowed_codes = allowed_codes + self.intraday_cache_ttl = intraday_cache_ttl + self.closing_cache_ttl = closing_cache_ttl + + @classmethod + def from_mapping(cls, value: object) -> "FundQuoteRuntimeConfig": + if not isinstance(value, dict): + return cls() + raw_codes = value.get("allowed_codes", SOUTHERN_FUND_CODES) + if isinstance(raw_codes, list): + codes = tuple( + code for code in raw_codes + if isinstance(code, str) and len(code) == 6 and code.isdigit() + ) + else: + codes = SOUTHERN_FUND_CODES + if not codes: + codes = SOUTHERN_FUND_CODES + def ttl(name: str, default: int) -> int: + raw = value.get(name, default) + return raw if isinstance(raw, int) and 1 <= raw <= 86400 else default + return cls( + allowed_codes=codes, + intraday_cache_ttl=ttl("intraday_cache_ttl", 60), + closing_cache_ttl=ttl("closing_cache_ttl", 900), + ) + + +class FundQuoteService: + def __init__( + self, + adapter: FundMarketAdapter, + cache: FundQuoteCache | None = None, + runtime_config: FundQuoteRuntimeConfig | None = None, + ) -> None: + self.adapter = adapter + self.cache = cache + self.runtime_config = runtime_config or FundQuoteRuntimeConfig() + + async def query( + self, + query: FundQuoteQuery, + *, + now: datetime | None = None, + ) -> list[FundQuote]: + if query.fund_type is not None and query.fund_type not in FUND_TYPE_GROUPS: + raise ValueError("基金类型必须是货币型、债券型、混合型或股票型") + configured = set(self.runtime_config.allowed_codes) + source_codes = ( + FUND_TYPE_GROUPS[query.fund_type] + if query.fund_type is not None + else SOUTHERN_FUND_CODES + ) + available = tuple(code for code in source_codes if code in configured) + selected = [code for code in query.fund_codes if code in available] + if not query.fund_codes: + selected = list(available) + selected = selected[: query.limit] + if not selected: + return [] + current = (now or datetime.now(UTC)).astimezone(SHANGHAI_TZ) + target_date = query.target_date or current.date() + intraday = self.is_market_trading_time(current) + cache_key = self._cache_key(query, target_date, intraday) + if self.cache is not None: + cached = await self.cache.get(cache_key) + if cached.value: + try: + values = [FundQuote.model_validate(item) for item in json.loads(cached.value)] + return [item.model_copy(update={"quote_source": "cache"}) for item in values] + except (TypeError, ValueError): + pass + names = await self.adapter.fetch_names(selected) + live = await self.adapter.fetch_quotes(selected) if intraday else {} + history = { + code: await self.adapter.fetch_history(code, target_date) for code in selected + } + quote_time = current + result = [ + self._merge(code, names, live, history, quote_time, intraday) + for code in selected + ] + if self.cache is not None: + ttl = (self.runtime_config.intraday_cache_ttl + if intraday else self.runtime_config.closing_cache_ttl) + await self.cache.set( + cache_key, + "[" + ",".join(item.model_dump_json() for item in result) + "]", + ttl, + ) + return result + + @staticmethod + def _cache_key(query: FundQuoteQuery, target_date: date, intraday: bool) -> str: + codes = ",".join(query.fund_codes) if query.fund_codes else "all" + return ( + f"fund_quote:v1:{codes}:{query.fund_type or 'all'}:" + f"{target_date}:{int(intraday)}:{query.limit}" + ) + + @staticmethod + def is_market_trading_time(current: datetime) -> bool: + local = current.astimezone(SHANGHAI_TZ) + if local.weekday() >= 5: + return False + value = local.time() + return time(9, 30) <= value <= time(11, 30) or time(13) <= value <= time(15) + + @staticmethod + def _merge( + code: str, + names: dict[str, str], + live: dict[str, dict[str, Any]], + history: dict[str, dict[str, Any]], + quote_time: datetime, + intraday: bool, + ) -> FundQuote: + current = live.get(code, {}) + previous = history.get(code, {}) + use_live = current.get("nav") is not None + degraded = bool(previous.get("degraded")) or (intraday and not use_live) + source: Literal["eastmoney", "cache", "degraded"] = ( + "eastmoney" if (use_live or not degraded) else "degraded" + ) + return FundQuote( + fund_code=code, + fund_name=names.get(code, f"基金 {code}"), + fund_type=FUND_TYPE_BY_CODE.get(code), + nav=current.get("nav") if use_live else previous.get("nav"), + nav_date=previous.get("nav_date"), + daily_change=current.get("daily_change") if use_live else previous.get("daily_change"), + half_year_return=previous.get("half_year_return"), + one_year_return=previous.get("one_year_return"), + year_to_date_return=previous.get("year_to_date_return"), + since_inception_return=previous.get("since_inception_return"), + quote_time=quote_time, + quote_source=source, + is_intraday=intraday, + degraded=degraded, + ) + + +async def query_fund_quote_tool( + arguments: FundQuoteQuery, context: RequestContext +) -> list[dict[str, Any]]: + """ToolExecutor 使用的公共只读行情工具处理器。""" + del context # 权限、审计和来源由 ToolExecutor 统一处理。 + from app.service.runtime_config_service import load_fund_quote_config + + try: + mapping = await load_fund_quote_config() + except Exception: + mapping = {} + config = FundQuoteRuntimeConfig.from_mapping(mapping) + result = await FundQuoteService( + EastmoneyAdapterFactory.create(), runtime_config=config + ).query(arguments) + return [item.model_dump(mode="json") for item in result] + + +class EastmoneyAdapterFactory: + """延迟创建外部 Adapter,避免导入底座时建立网络连接。""" + + @staticmethod + def create() -> FundMarketAdapter: + from app.infrastructure.fund_market_adapter import EastmoneyFundAdapter + + return EastmoneyFundAdapter() diff --git a/app/service/health_service.py b/app/service/health_service.py index 7129a55..8707da0 100644 --- a/app/service/health_service.py +++ b/app/service/health_service.py @@ -2,6 +2,7 @@ from typing import Any from sqlalchemy import text +from app.core.config import get_settings from app.infrastructure.db import SessionFactory @@ -14,7 +15,29 @@ class HealthService: checks["mysql"] = True except Exception: checks["mysql"] = False - # Redis/Milvus adapters are intentionally optional projections. - checks["redis"] = True + checks["redis"] = await self._redis_ready() + # Milvus remains an optional projection; its dedicated adapter reports degraded reads. checks["milvus"] = True return {"status": "ready" if all(checks.values()) else "degraded", "checks": checks} + + async def _redis_ready(self) -> bool: + client = None + try: + from redis.asyncio import Redis + + settings = get_settings() + client = Redis.from_url( + settings.redis_url, + socket_connect_timeout=settings.redis_connect_timeout_seconds, + socket_timeout=settings.redis_connect_timeout_seconds, + decode_responses=True, + ) + return bool(await client.ping()) + except Exception: + return False + finally: + if client is not None: + try: + await client.aclose() + except Exception: + pass diff --git a/app/service/runtime_config_service.py b/app/service/runtime_config_service.py index 501d53a..db378f3 100644 --- a/app/service/runtime_config_service.py +++ b/app/service/runtime_config_service.py @@ -1,7 +1,13 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.model.configuration import AgentIntentConfig, PlatformConfigItem, PromptTemplateVersion +from app.infrastructure.db import SessionFactory +from app.model.configuration import ( + AgentIntentConfig, + ConfigRelease, + PlatformConfigItem, + PromptTemplateVersion, +) class RuntimeConfigService: @@ -51,3 +57,25 @@ class RuntimeConfigService: if not isinstance(raw_tools, list) or not all(isinstance(tool, str) for tool in raw_tools): raise ValueError("invalid tool whitelist configuration") return tuple(raw_tools) + + async def fund_quote(self, release_id: int) -> dict[str, object]: + """读取已发布行情配置;缺失时返回空映射,由 Service 使用安全默认值。""" + item = await self.session.scalar( + select(PlatformConfigItem).where( + PlatformConfigItem.release_id == release_id, + PlatformConfigItem.namespace == "fund_market", + PlatformConfigItem.config_key == "default", + ) + ) + return item.value_json if item is not None else {} + + +async def load_fund_quote_config() -> dict[str, object]: + """读取当前激活行情配置;配置中心不可用时交给调用方使用默认值。""" + async with SessionFactory() as session: + release = await session.scalar( + select(ConfigRelease).where(ConfigRelease.status == "active") + ) + if release is None: + return {} + return await RuntimeConfigService(session).fund_quote(release.id) diff --git a/docs/06-底座代码测试报告.md b/docs/06-底座代码测试报告.md index 35f62f2..560c845 100644 --- a/docs/06-底座代码测试报告.md +++ b/docs/06-底座代码测试报告.md @@ -331,6 +331,7 @@ $env:PYTHONIOENCODING='utf-8' | v1.0 | 2026-09-09 | 首版:5 类测试执行结果、15 项缺陷清单、契约与架构核对、评分与修复优先级 | | v1.1 | 2026-09-09 | 修正接口总数(46 → 50)并区分全量/当前阶段双口径;新增 §6.2 TODO 阶段完成度校准;修正评分计算口径(综合 55 → 48,契约完整度 25 → 45) | | v2.0 | 2026-09-09 | 新增附录 D:gpt 修复后的独立复审结果(综合 48 → 90) | +| v3.0 | 2026-09-09 | 新增附录 E:能力接线复审(综合 90 → 94);生产组装、意图分类接入、首个业务工具闭环 | --- @@ -405,3 +406,72 @@ $env:PYTHONIOENCODING='utf-8' | `tools/acceptance_check.py` | 独立端到端验收(越权、闭环、SSE) | | `tools/seed_test_rbac.py` | RBAC 测试数据种子 + 身份加载验证 | | `tools/smoke_check.py` | HTTP 冒烟(认证、幂等、异常输入) | + +--- + +## 附录 E:v3.0 能力接线复审 + +> 复审对象:新增能力接线与首个业务工具(89 个源文件,116 个测试用例) +> 复审依据:本报告附录 D 遗留 + `docs/10-业务域接入评估.md` 缺口清单 +> **复审结论:能力层与组装层全部闭环,综合 90 → 94(A)。** + +### E.1 上轮遗留的闭环情况 + +| 附录 D 遗留 | 本轮 | 证据 | +|---|---|---| +| 生产零注册,能力无法调用 | ✅ 组装入口补齐 | `bootstrap.get_agent_factory()` 注册 2 个内置工具、构造 `DatabaseModelGateway`、注入 `ModelGenerationService` / `ToolExecutor` / `IntentClassifier` | +| 治理钩子部分覆盖 | ✅ 补齐 | 模型调用、工具执行器、适当性校验、意图分类全部接线 | +| `IntentClassifier` 未接入骨架 | ✅ 已接入 | `base.py:104` 在 `execute()` 调用 `classify_intent()`;禁覆盖方法增至 13 个 | +| 健康检查 redis 硬编码 | ✅ 改真实 ping | `health_service.py:18,23-43` | +| 缺骨架契约测试 | ✅ 已补 | `tests/contract/test_agent_factory_contract.py`(遍历注册表) | +| `knowledge_service` 空壳 | ⚠️ 未处理 | 仍无条件 404(见 E.4) | + +### E.2 本轮新增能力 + +| 能力 | 实现 | 质量要点 | +|---|---|---| +| 模型网关 adapter | `OpenAICompatibleGateway` + `DatabaseModelGateway` | `secret_ref` 强制 `env:` 前缀,密钥不入库不入日志 | +| 工具执行器 | `ToolExecutor` + `ToolRegistry` | 强制只读、参数全 `[redacted]`、超时控制、审计 | +| 适当性校验 | `SuitabilityService` | 时区校验、C1-C5/R1-R5、通过与拒绝均留痕 | +| 意图分类 | `IntentClassifier` | 严格 JSON、意图白名单校验、低置信 `needs_clarification` | +| **首个业务工具** | `fund_quote_service.py`(209 行) | 交易时段判断、盘中/收盘分档缓存、降级标记、只读约束、延迟建连 | + +### E.3 独立验证 + +```text +pytest tests → 116 passed(附录 D 时 77) +ruff check → 0 +mypy app → 89 文件 0 错误(附录 D 时 82) + +开箱验证 tools/onboarding_check.py(不经替身): + 生产工厂 : ModelGenerationService + ToolExecutor + PlatformGovernance + create() 后 : 能力自动绑定 + 配置解析 : config_version 正常,tools_by_intent={'general': ()} + 工具调用 : ForbiddenAgentError 工具不在当前意图白名单(失败关闭,链路正确) +``` + +### E.4 遗留 + +| # | 项 | 影响 | 建议 | +|---|---|---|---| +| 1 | `knowledge_service.py` 仍无条件 404 | 客服 RAG 引用链路消费端为空 | 接 Milvus 检索后替换占位实现 | +| 2 | `milvus` 健康检查仍为 `True` | 有注释说明"optional projection",但探针仍会误判 | 改为 `not_probed` 或接入真实探测 | +| 3 | 开箱状态下工具不可用 | active release 的 `tools_by_intent` 为空 | 属正常安全设计,组员需先发布配置 | + +### E.5 评分更新 + +| 维度 | v2.0 | v3.0 | 依据 | +|---|---|---|---| +| 工程基建 | 90 | **92** | 测试 77→116,mypy 82→89 文件,新增契约测试 | +| 安全与鉴权 | 88 | 88 | 无变化 | +| 契约实现完整度 | 95 | 95 | 无变化 | +| MVC+S 架构合规 | 92 | 92 | 无变化 | +| 端到端可运行性 | 85 | **92** | 生产组装完整,开箱验证通过 | +| 能力实现质量 | — | **94** | 四个服务 + 首个业务工具样板 | +| **综合** | **90** | **94** | A | + +### E.6 复审交付物 + +| 文件 | 用途 | +|---|---| +| `tools/onboarding_check.py` | 开箱可用性验证(生产工厂 → 能力绑定 → 工具调用) | diff --git a/docs/09-底座使用文档.md b/docs/09-底座使用文档.md index 0f69b78..11c2c06 100644 --- a/docs/09-底座使用文档.md +++ b/docs/09-底座使用文档.md @@ -381,3 +381,10 @@ python tools/audit_schema.py 客服、投顾、风控和运营 Agent 的具体意图、提示词、工具实现和业务状态机由业务组员负责,但必须通过本文规定的工厂和公共执行流程接入。 当前系统业务交易范围是场内基金模拟交易。场外运营数据必须使用独立表和独立接口,不得写入场内交易表。 + +## 14. 基金行情工具当前状态 + +底座已注册 `query_fund_quote` 公共只读工具,经过 `ToolRegistry`、`ToolExecutor` 和统一工厂注入。 +业务 Agent 必须同时在 `AgentDefinition.allowed_tools` 和发布配置的 +`agent_tools/:fund_quote` 中声明后才能使用。未注册业务 Agent 不能直接提交运行, +底座也没有开放绕过 Agent 执行链的行情 HTTP 接口。 diff --git a/docs/10-业务域接入评估.md b/docs/10-业务域接入评估.md index d05708e..d118b96 100644 --- a/docs/10-业务域接入评估.md +++ b/docs/10-业务域接入评估.md @@ -240,8 +240,52 @@ ConfigRelease → ModelRouterService → ModelGateway → 主端点/受控 fallb --- -## 10. 变更记录 +## 10. 缺口闭环更新(v1.1) + +> 本节记录 v1.0 所列缺口在后续两轮开发中的闭环情况。§1-§9 保留为历史证据,不再代表当前状态。 + +### 10.1 缺口状态对照 + +| 原缺口 | v1.0 | 当前 | 证据 | +|---|---|---|---| +| 模型生成出口 | ❌ 缺失 | ✅ 闭环 | `OpenAICompatibleGateway`、`DatabaseModelGateway`、`BaseAgent.generate_with_model` | +| 工具执行器 | ❌ 缺失 | ✅ 闭环 | `ToolExecutor` + `ToolRegistry`,`bootstrap` 注册 2 个内置工具 | +| 适当性校验 | ❌ 缺失 | ✅ 闭环 | `SuitabilityService`(C1-C5/R1-R5 + 有效期 + 双向留痕) | +| 意图分类器 | ❌ 缺失 | ✅ 闭环 | `base.py:104` 骨架调用 `classify_intent()` | +| 生产组装入口 | ❌ 空工厂 | ✅ 闭环 | `bootstrap.get_agent_factory()` 完整组装并注入 | +| 骨架契约测试 | ❌ 缺失 | ✅ 已补 | `tests/contract/test_agent_factory_contract.py` | +| 业务工具样板 | ❌ 无 | ✅ 已提供 | `fund_quote_service.py`(基金行情,209 行) | +| 健康检查 redis | ❌ 硬编码 | ✅ 已修 | 真实 ping | +| 健康检查 milvus | ❌ 硬编码 | ⚠️ 保留 | 有注释说明,建议改 `not_probed` | +| Milvus 知识检索 | ❌ 缺失 | ⚠️ 未闭环 | 客服 RAG 仍阻塞 | +| `knowledge_service` 空壳 | ⚠️ 无条件 404 | ⚠️ 未处理 | 同上一项 | + +### 10.2 四域可开工判定(更新) + +| 业务域 | v1.0 判定 | 当前判定 | 说明 | +|---|---|---|---| +| **客服** | ❌ 阻塞 | ⚠️ 部分可开工 | 意图分类、转人工、负面词、SSE 已就绪;**RAG 检索仍缺 Milvus 实现** | +| **风控** | ❌ 阻塞 | ✅ 可开工 | 只读工具机制就绪;日报模型建议可用 | +| **投顾** | ❌ 阻塞 | ✅ 可开工 | 适当性硬约束、方案生成、工具调用均已就绪 | +| **运营** | ❌ 阻塞 | ⚠️ 部分可开工 | 模型提取/生成可用;**邮件接入与 NL2SQL 需组员自建工具**;需求文档仍过简 | + +### 10.3 剩余待办 + +| 优先级 | 事项 | 解锁范围 | +|---|---|---| +| P1 | Milvus 知识检索 + 修复 `knowledge_service` 空壳 | 客服 RAG | +| P2 | `milvus` 健康检查改为 `not_probed` 或真实探测 | 运维 | +| P2 | 运营域需求文档补充接口与字段定义 | 运营域开工 | + +### 10.4 结论 + +底座的两个公共出口(模型、工具)已闭环,并有首个业务工具样板可照抄。**§5 的"四域 100% 阻塞"结论在 v1.1 后不再成立**:风控与投顾已具备完整开工条件,客服与运营除各自一项外部依赖(Milvus / 邮件与 NL2SQL)外均可开工。 + +--- + +## 11. 变更记录 | 版本 | 日期 | 变更 | |---|---|---| | v1.0 | 2026-09-09 | 首版:四域需求映射、公共阻塞点分析、空壳接口与健康检查缺陷、补课顺序建议 | +| v1.1 | 2026-09-09 | 新增 §10 缺口闭环更新:模型/工具/适当性/意图分类/组装/契约测试全部闭环,四域可开工判定更新,综合由"全阻塞"改为"风控投顾可开工" | diff --git a/docs/11-业务组员Agent接入使用说明书.md b/docs/11-业务组员Agent接入使用说明书.md index 30e5f6b..c89e0da 100644 --- a/docs/11-业务组员Agent接入使用说明书.md +++ b/docs/11-业务组员Agent接入使用说明书.md @@ -10,6 +10,15 @@ 当前业务范围是场内基金模拟交易;场外运营必须使用独立表和独立接口,不得写入场内交易表。 +## 1.1 当前可用的公共行情工具 + +底座已经注册公共只读工具 `query_fund_quote`,可供客服、投顾和风控 Agent 使用。它已经接入 +`ToolRegistry → ToolExecutor → AgentFactory`,会统一执行权限、角色、意图白名单、参数校验、 +超时、审计、来源引用和行情降级处理。 + +注意:工具已在底座注册,不代表所有业务 Agent 自动拥有它。业务 Agent 必须在代码和发布配置中 +显式声明后才能调用;当前没有注册的业务 Agent 仍不能直接提交运行。 + ## 2. 组员需要提交什么 每个 Agent 接入至少提交以下内容: @@ -66,6 +75,46 @@ async def handle(self, request: AgentRequest, context: RequestContext) -> CoreRe 不要在 `handle()` 中读取 `request` 之外的客户端身份字段。客户身份以服务端生成的 `context` 为准。 +### 3.3 接入基金行情工具 + +如果业务需要查询基金行情,在 `AgentDefinition` 中声明: + +```python +allowed_tools=("query_fund_quote",) +supported_intents=("fund_quote", "general") +``` + +然后在配置中心对应的已发布版本中允许: + +```text +namespace: agent_tools +config_key: :fund_quote +value_json: {"allowed_tools": ["query_fund_quote"]} +``` + +业务代码通过底座方法调用: + +```python +quote = await self.call_tool( + "query_fund_quote", + {"fund_codes": ["159511"], "limit": 20}, + intent="fund_quote", + context=context, +) +``` + +返回结果中的关键字段: + +```text +quote_source: eastmoney | cache | degraded +is_intraday: 是否盘中行情 +degraded: 是否降级 +nav_date: 净值对应日期 +``` + +`degraded=true` 时只能进行说明和分析,不能把结果表述为成交、委托、持仓或实时保证。 +组员不得直接导入 `hq.py`、调用东方财富 URL、导入 `httpx` 或自行读取行情缓存。 + ## 4. 注册 Agent 在 `app/service/agent/bootstrap.py` 的统一注册位置登记。HTTP 服务和 Worker 会使用同一个工厂。 @@ -96,6 +145,15 @@ factory.register( → SSE/查询返回结果 ``` +### 公共基金行情工具 + +底座已提供只读工具 `query_fund_quote`,权限码为 `fund:quote:read`,允许角色包括 +`customer`、`advisor`、`operator`、`risk_operator` 和 `admin`。该工具内部负责外部行情请求、 +盘中/收盘判断、短缓存和降级标记。组员只需在自己的 `AgentDefinition.allowed_tools` 和发布的 +意图白名单中声明它,然后通过 `self.call_tool(...)` 调用,不得导入外部行情脚本。 +行情白名单和缓存 TTL 可由已发布的 `fund_market/default` 配置调整;配置缺失或非法时底座自动使用 +安全默认值,组员无需读取或解析配置中心。 + ### 意图分类 组员不需要创建分类器或传 `endpoints`。底座从当前激活的 `model_endpoint_config` 读取模型端点, diff --git a/docs/12-基金行情数据底座接入开发计划.md b/docs/12-基金行情数据底座接入开发计划.md new file mode 100644 index 0000000..2f49179 --- /dev/null +++ b/docs/12-基金行情数据底座接入开发计划.md @@ -0,0 +1,259 @@ +# 基金行情数据底座接入开发计划 + +> 版本:v1.0 +> 目标:将外部基金行情能力接入公共 MVC+S 底座,供客服、投顾、风控 Agent 统一只读使用。 +> 当前范围:场内基金模拟交易相关行情;场外运营数据不纳入本计划。 + +## 一、建设目标 + +将外部行情脚本改造成公共底座能力: + +```text +东方财富 Adapter +→ FundQuoteService +→ ToolRegistry / ToolExecutor +→ 客服、投顾、风控 Agent +``` + +业务组员只调用统一工具,不直接访问东方财富接口、HTTP 客户端、缓存或数据库。 + +## 二、明确不做的事情 + +- 第一阶段不修改已有数据库表、字段和交易流程; +- 不把行情快照写入场内交易表; +- 不实现代客下单、成交确认或持仓修改; +- 不让 Agent 直接调用外部行情接口; +- 不在本阶段创建可运行示例 Agent; +- 不把场外基金运营数据混入本行情服务。 + +## 三、现有脚本拆分映射 + +| 原 `hq.py` 能力 | 新底座位置 | 处理要求 | +|---|---|---| +| 东方财富 HTTP 调用 | `app/infrastructure/fund_market_adapter.py` | 改为异步、超时、重试、响应校验 | +| 交易时段判断 | `FundQuoteService` | 固定北京时间并可测试注入时钟 | +| 基金名称缓存 | 行情缓存适配器 | 允许 Redis,不使用进程全局作为唯一缓存 | +| 历史净值和收益率合并 | `app/service/fund_quote_service.py` | 统一 DTO、Decimal、降级标识 | +| 中文字典输出 | Controller/Agent 展示层 | 内部 DTO 使用英文稳定字段 | +| 固定南方基金代码 | 配置或代码白名单 | 先保留白名单,后续配置中心化 | + +## 四、阶段计划 + +### F0:基线和契约冻结 + +**目标**:确认接入不会破坏现有底座和数据库规则。 + +**任务**: + +- 对照 `AGENTS.md`、00 基线和 02 建表设计; +- 确认本阶段只新增 Python 模块、工具注册和测试; +- 定义 `FundQuoteQuery`、`FundQuote`、`FundQuoteSource` DTO; +- 定义错误分类:外部行情不可用返回可识别的降级结果,不泄露供应商异常; +- 确认行情工具权限码为 `fund:quote:read`。 + +**验收**: + +- DTO 字段、类型和空值规则确定; +- 没有数据库迁移; +- 没有修改既有表和字段; +- 接口契约可被 Service、ToolExecutor 和测试共同引用。 + +### F1:外部行情 Adapter + +**目标**:把 `hq.py` 的外部请求改造成底座 Infrastructure 适配器。 + +**新增文件**: + +```text +app/infrastructure/fund_market_adapter.py +app/core/fund_contracts.py +tests/unit/infrastructure/test_fund_market_adapter.py +``` + +**任务**: + +- 使用 `httpx.AsyncClient`,禁止同步网络调用阻塞 Worker; +- 封装名称、实时行情、历史净值、区间收益率四类请求; +- 统一请求超时、有限重试和指数退避; +- 校验外部 JSON 结构和基金代码; +- 将 HTTP、JSON、字段缺失转换为 `RecoverableAgentError` 或降级结果; +- 供应商日志只记录接口类别、基金数量和异常类型,不记录完整响应正文; +- 使用 `Decimal` 保存净值和收益率; +- 所有时间统一使用 Asia/Shanghai 语义。 + +**验收**: + +- 外部接口成功、超时、HTTP 错误、非法 JSON、字段缺失均有测试; +- 测试不依赖真实东方财富网络,使用 `httpx.MockTransport`; +- Adapter 不导入 Agent、Controller 或数据库 Session。 + +### F2:FundQuoteService + +**目标**:将多个外部接口结果合并成稳定的行情业务结果。 + +**新增文件**: + +```text +app/service/fund_quote_service.py +tests/unit/service/test_fund_quote_service.py +``` + +**任务**: + +- 实现按基金代码、基金类型、日期和数量查询; +- 保留场内基金白名单; +- 合并实时行情和历史净值; +- 盘中使用实时行情,非盘中使用最新收盘净值; +- 返回 `quote_source`、`quote_time`、`is_intraday`、`degraded`; +- 单个产品失败不得导致全部产品无原因失败; +- 外部行情不可用时返回结构化降级结果; +- 不将实时行情解释为成交、委托或持仓结果。 + +**验收**: + +- 交易时段和非交易时段测试通过; +- 基金类型筛选、数量限制、非法日期和未知类型测试通过; +- 实时失败回退历史、历史失败返回降级结果的测试通过; +- Service 不直接解析 HTTP 响应细节。 + +### F3:缓存和健康状态 + +**目标**:降低外部接口压力,并让组员得到可识别的降级状态。 + +**任务**: + +- 基金名称缓存建议 1 天; +- 收盘净值缓存建议 5-30 分钟; +- 实时行情缓存建议 30-60 秒; +- Redis 可用时使用 Redis,不可用时回退无缓存请求或结构化降级; +- 不将进程级字典作为多 Worker 唯一缓存; +- 增加行情数据源健康状态,不把供应商故障伪装成正常行情; +- 记录数据源成功率、延迟、降级次数和最后成功时间。 + +**验收**: + +- Redis 正常、Redis 不可用、缓存过期和缓存击穿测试通过; +- 缓存故障不阻塞主流程; +- 返回结果能够区分实时、缓存、收盘和降级来源。 + +### F4:注册公共只读工具 + +**目标**:让所有业务 Agent 通过统一工具使用行情。 + +**工具契约**: + +```text +工具名:query_fund_quote +权限码:fund:quote:read +只读:是 +允许角色:customer、advisor、operator、risk_operator、admin +超时:5 秒 +``` + +**任务**: + +- 定义严格的 Pydantic 输入模型; +- 在 `bootstrap` 中注册 `ToolDefinition`; +- 由 `ToolExecutor` 统一检查权限、角色、意图白名单和超时; +- 结果自动生成工具来源引用和审计记录; +- `AgentDefinition.allowed_tools` 只能缩小权限; +- 业务 Agent 不能绕过工具调用 Adapter。 + +**验收**: + +- 正常调用、未授权、未列入意图白名单、参数错误、超时和外部失败均有测试; +- 工具结果不含供应商原始异常和敏感配置; +- 来源引用能通过公共合规审查。 + +### F5:配置中心和管理规则 + +**目标**:把基金白名单、数据源开关、缓存时间和限流参数逐步纳入配置治理。 + +**第一阶段**: + +- 外部接口地址、超时和密钥引用写入 `.env` 或运行配置; +- 基金白名单保留在代码常量,避免未经审核扩大产品范围。 + +**后续阶段**: + +- 使用 `platform_config_item` 增加行情配置命名空间; +- 配置发布、审核、激活、回滚必须经过已有配置中心; +- 配置变更写审计并通过 Outbox 通知缓存失效; +- 不通过配置中心扩大 Agent 的工具权限上限。 + +**验收**: + +- 配置缺失时失败关闭或使用明确的安全默认值; +- 密钥只使用 `secret_ref`,不返回明文; +- 发布版本可追溯、可回滚。 + +**当前实现**:`RuntimeConfigService.fund_quote()` 读取当前激活版本的 +`namespace=fund_market、config_key=default`;`query_fund_quote` 工具加载该配置, +`FundQuoteRuntimeConfig` 校验基金白名单和缓存 TTL。配置缺失、类型错误或配置中心异常时使用代码 +默认值,不会因配置中心异常导致行情功能整体不可用。 + +### F6:业务组员接入 + +**目标**:客服、投顾和风控 Agent 只消费统一行情工具。 + +**组员需要做**: + +- 在 `AgentDefinition.allowed_tools` 声明 `query_fund_quote`; +- 在对应意图白名单中声明 `fund_quote`; +- 在 `handle()` 中调用 `self.call_tool(...)`; +- 对行情缺失、降级和非交易时段给出业务解释; +- 补充本业务域的权限和边界测试。 + +**组员禁止做**: + +- 直接导入 `hq.py`; +- 直接调用东方财富 URL; +- 自己读取行情缓存或解析外部 JSON; +- 根据行情直接下单或修改交易数据; +- 把行情数据写入交易表。 + +### F7:是否落行情快照表 + +**判断条件**:只有在需要历史回测、监管留痕、行情对账或跨进程长期查询时才进入本阶段。 + +**候选新增表**: + +```text +fund_quote_snapshot +fund_quote_source_record +``` + +**前置要求**: + +- 先更新 00、02 数据库文档; +- 证明没有修改任何既有表名和字段定义; +- 设计唯一键、来源、采集时间、有效时间和幂等策略; +- 增加 Alembic 迁移、schema fingerprint 和真实 MySQL 验收; +- 只新增表,不改写场内交易表。 + +## 五、建议开发顺序和完成标准 + +```text +F0 契约冻结 +→ F1 Adapter +→ F2 Service +→ F3 缓存/健康 +→ F4 公共工具 +→ F5 配置治理 +→ F6 业务接入 +→ F7 可选落库 +``` + +MVP 完成标准:F0-F4 全部通过;不要求新增数据库表。长期版本再评估 F5-F7。 + +## 六、测试命令 + +```powershell +python -m pytest tests/unit/infrastructure/test_fund_market_adapter.py tests/unit/service/test_fund_quote_service.py -q -p no:cacheprovider +python -m pytest -q -p no:cacheprovider +python -m ruff check app tests tools alembic +python -m mypy app +python tools/audit_schema.py +``` + +任何数据库变更都必须额外执行 schema fingerprint,并在 TODO 中记录基线对比结果。 diff --git a/docs/13-基金行情工具业务接入清单.md b/docs/13-基金行情工具业务接入清单.md new file mode 100644 index 0000000..efd884c --- /dev/null +++ b/docs/13-基金行情工具业务接入清单.md @@ -0,0 +1,87 @@ +# 基金行情工具业务接入清单 + +## 适用范围 + +客服、投顾、风控 Agent 如需查询场内基金行情,统一使用公共工具 `query_fund_quote`。 +本清单只规范接入方式,不创建业务 Agent,也不替业务组实现具体意图。 + +## 业务组员需要完成的事项 + +### 1. 在 AgentDefinition 声明工具 + +```python +allowed_tools=("query_fund_quote",) +``` + +工具权限不能通过数据库配置扩大。代码声明是上限,发布配置只能缩小范围。 + +### 2. 声明业务意图 + +在 `supported_intents` 中加入业务自己的意图,例如: + +```python +supported_intents=("fund_quote", "general") +``` + +然后在已发布配置中,将本 Agent 的 `fund_quote` 意图允许使用: + +```json +{ + "allowed_tools": ["query_fund_quote"] +} +``` + +配置键格式为: + +```text +agent_tools / :fund_quote +``` + +### 3. 在 handle 中调用公共工具 + +```python +quote = await self.call_tool( + "query_fund_quote", + {"fund_codes": ["159511"], "limit": 20}, + intent="fund_quote", + context=context, +) +``` + +不得导入 `hq.py`、`httpx`、东方财富 URL 或自行读取行情缓存。 + +### 4. 处理降级结果 + +必须识别以下字段: + +- `quote_source`:`eastmoney`、`cache` 或 `degraded`; +- `is_intraday`:是否盘中数据; +- `degraded`:是否处于降级状态; +- `nav_date`:净值对应日期。 + +降级行情只能用于说明或分析,不能当作成交、委托、持仓或实时保证。 + +## 业务边界 + +- 客服:可以解释行情字段和数据时间; +- 投顾:可以基于行情做分析,但仍须通过适当性校验; +- 风控:可以查询行情辅助风险分析; +- 所有 Agent:不得代客下单、修改持仓、确认成交或改变交易数据。 + +## 提交前检查 + +- [ ] `AgentDefinition.allowed_tools` 包含 `query_fund_quote`; +- [ ] `supported_intents` 包含实际使用的意图; +- [ ] 发布配置只允许必要的工具; +- [ ] 正常、未授权、未配置白名单、超时和降级测试齐全; +- [ ] 回答没有把行情当成成交确认; +- [ ] 没有直接调用外部行情接口; +- [ ] 注册表契约测试通过。 + +底座负责人验收命令: + +```powershell +python -m pytest tests/contract/test_agent_factory_contract.py -q -p no:cacheprovider +python -m ruff check app tests tools alembic +python -m mypy app +``` diff --git a/docs/14-Agent组员统一接入说明书.md b/docs/14-Agent组员统一接入说明书.md new file mode 100644 index 0000000..3b26661 --- /dev/null +++ b/docs/14-Agent组员统一接入说明书.md @@ -0,0 +1,293 @@ +# Agent 组员统一接入说明书 + +> 版本:v1.0 +> 适用对象:客服、投顾、风控及其他业务 Agent 开发人员 +> 阅读要求:组员开发 Agent 前必须完整阅读本文。 + +本文是业务组员接入底座的统一入口,整合了底座启动、Agent 注册、公共执行链、模型、意图、工具、 +适当性、基金行情和提交验收要求。接口字段以《05-接口文档.md》为准,数据库以《00-新数据库基线设计.md》 +和《02-数据库建表设计.md》为准。 + +## 1. 你负责什么,底座负责什么 + +业务 Agent 属于 MVC+S 架构的 Service 层。组员只负责业务意图、业务编排和业务输出,不负责重建公共底座。 + +组员负责: + +- `AgentDefinition`; +- 继承 `BaseAgent` 的业务类; +- `handle()` 内的业务判断和编排; +- 业务只读工具声明及测试; +- 注册表登记; +- 业务边界和权限测试。 + +底座负责: + +- JWT、RBAC、入口和客户范围校验; +- 配置快照和记忆召回; +- 意图分类和低置信处理; +- 模型路由、密钥引用和 fallback; +- 工具权限、超时、审计和来源引用; +- 适当性校验; +- 合规审查、敏感信息脱敏; +- 运行持久化、Outbox、SSE 和恢复。 + +业务范围只包括场内基金模拟交易。场外运营必须独立建表、独立接口,不得写入场内交易表。 + +## 2. 接入前提 + +项目使用 Python 3.13: + +```powershell +conda activate jr_py313 +pip install -r requirements.txt +``` + +复制配置模板并填写本机环境: + +```powershell +Copy-Item .env.example .env +``` + +启动 HTTP 服务和 Worker: + +```powershell +python -m uvicorn app.main:app --host 127.0.0.1 --port 8099 +python -m app.worker +``` + +数据库迁移只能通过 Alembic: + +```powershell +alembic upgrade head +python tools/audit_schema.py +``` + +`.env`、JWT 私钥、模型 API Key 不得提交到 Git。 + +## 3. 定义 Agent + +`agent_type` 必须使用小写蛇形命名,例如 `customer_service`、`advisor`、`risk`。 +`supported_intents` 列出该 Agent 支持的全部意图;配置中心不能扩大代码中声明的工具上限。 + +```python +from app.core.contracts import AgentDefinition, AgentRequest, CoreResult, RequestContext +from app.service.agent.base import BaseAgent + + +class AdvisorAgent(BaseAgent): + definition = AgentDefinition( + agent_type="advisor", + version="1.0.0", + allowed_roles=("customer", "advisor", "operator", "admin"), + allowed_portals=("api",), + allowed_tools=("query_fund_quote",), + supported_intents=("fund_quote", "general"), + ) + + async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult: + return CoreResult(text="业务处理结果") +``` + +业务类只实现 `handle()`。不要覆盖 `execute()`、鉴权、配置、记忆、意图分类、模型、工具或合规方法。 + +## 4. 注册 Agent + +在 `app/service/agent/bootstrap.py` 的统一注册入口登记: + +```python +factory.register( + AdvisorAgent.definition, + lambda _context: AdvisorAgent(AdvisorAgent.definition), +) +``` + +HTTP 服务和 Worker 使用同一个工厂,不能各自维护注册表。构造器必须返回 `BaseAgent`,且实例的 +`AgentDefinition` 必须与注册定义完全一致。 + +注册完成后必须通过: + +```powershell +python -m pytest tests/contract/test_agent_factory_contract.py -q -p no:cacheprovider +``` + +未注册的 Agent 类型会返回 `404 AGENT_TYPE_NOT_FOUND`。 + +## 5. 公共执行顺序 + +每次运行固定经过: + +```text +输入校验 +→ JWT/RBAC/入口/客户范围校验 +→ 读取发布配置 +→ 召回授权记忆 +→ 自动意图分类 +→ 执行 handle() +→ 模型、工具和适当性治理 +→ 引用、禁止表达和敏感信息审查 +→ complete_run 同事务持久化、审计和 Outbox +→ 查询/SSE 返回结果 +``` + +组员只能读取 `self.config`、`self.memories`、`request` 和服务端生成的 `context`。客户身份以 `context` +为准,不信任客户端自行传入的身份字段。 + +## 6. 意图分类 + +组员不需要创建 `IntentClassifier`、调用 `ModelRouterService` 或传 `endpoints`。底座会在 `handle()` +前从当前激活的模型端点自动分类,并把结果写入 `CoreResult.intent`。 + +模型输出必须是严格 JSON: + +```json +{"intent":"fund_quote","confidence":0.92} +``` + +底座会校验: + +- 意图必须属于 `supported_intents`; +- `confidence` 必须在 0 到 1; +- 低于配置阈值时 `needs_clarification=true`; +- 非法 JSON、未声明意图和空输入失败关闭。 + +低置信结果不能当作确定意图继续执行高风险业务。 + +## 7. 模型调用 + +模型链路固定为: + +```text +ConfigRelease → ModelRouterService → ModelGateway → 主端点/受控 fallback +``` + +业务 Agent 只能调用底座注入的方法: + +```python +result = await self.generate_with_model(endpoints, prompt) +``` + +禁止: + +- 导入 `httpx` 或供应商 SDK; +- 自己读取模型密钥; +- 自己选择未批准端点; +- 把模型原始异常返回给用户。 + +模型密钥只能使用 `secret_ref`,不能写入代码、数据库明文、日志或接口响应。 + +## 8. 公共工具调用 + +工具必须通过工厂注入的 `ToolExecutor` 调用: + +```python +value = await self.call_tool( + "工具名", + {"参数": "值"}, + intent="当前意图", + context=context, +) +``` + +每个工具必须声明 Pydantic 输入模型、权限码、允许角色、只读属性和超时。底座统一执行参数校验、 +意图白名单、角色权限、超时、脱敏摘要、审计和来源引用。 + +## 9. 基金行情工具 + +底座已提供公共只读工具: + +```text +工具名:query_fund_quote +权限码:fund:quote:read +允许角色:customer、advisor、operator、risk_operator、admin +``` + +### 9.1 代码和配置声明 + +在 AgentDefinition 中声明: + +```python +allowed_tools=("query_fund_quote",) +supported_intents=("fund_quote", "general") +``` + +在已发布配置中声明: + +```text +namespace: agent_tools +config_key: :fund_quote +value_json: {"allowed_tools": ["query_fund_quote"]} +``` + +### 9.2 调用方式 + +```python +quote = await self.call_tool( + "query_fund_quote", + {"fund_codes": ["159511"], "limit": 20}, + intent="fund_quote", + context=context, +) +``` + +返回结果重点关注: + +- `quote_source`:`eastmoney`、`cache` 或 `degraded`; +- `is_intraday`:是否盘中行情; +- `degraded`:是否降级; +- `nav_date`:净值对应日期。 + +降级行情只能用于说明和分析,不能表述为成交、委托、持仓或实时保证。 + +禁止直接导入 `hq.py`、调用东方财富 URL、使用 `httpx` 或自行读取行情缓存。 + +## 10. 适当性校验 + +投顾相关流程必须调用公共 `SuitabilityService` 或 `check_suitability` 工具,不得复制 C1-C5/R1-R5 +规则。客户风险等级低于产品风险等级、测评过期时必须拒绝;专业投资者也不能绕过审计。 + +通过和拒绝决定都会写入 `interaction_audit`,服务只读,不修改交易、产品或客户风险资料。 + +## 11. 记忆、关系和转人工 + +- 记忆提取由 `complete_run()` 在同一事务写入 Outbox,组员不直接调用提取服务; +- 记忆只能读取当前客户授权范围; +- Neo4j 查询必须经过 `RelationshipService` 和关系白名单; +- Agent 只能提出转人工请求,不能分配、接单、解决或关闭工单; +- 不能伪造 `SourceReference`,只能引用本次授权召回或工具真实返回的数据。 + +## 12. 禁止事项 + +- 禁止绕过 `AgentFactory`、`BaseAgent`、统一鉴权、记忆、模型、工具、合规、审计和事件流程; +- 禁止直接创建 SQLAlchemy Session、访问 MySQL、Redis、Milvus 或 Neo4j Driver; +- 禁止代客下单、确认成交、修改持仓、审核方案、处置或关闭风险预警; +- 禁止把场外运营数据写入场内交易表; +- 禁止重命名、删除或修改已有数据库表和字段;数据库需求只能新增表或字段并先对照基线。 + +## 13. 提交前测试 + +业务 Agent 至少覆盖: + +- 正常意图、低置信意图和模型格式错误; +- 未授权角色、入口、权限和客户范围; +- 工具白名单、参数错误、超时和失败关闭; +- 适当性通过、风险不匹配和测评过期; +- 行情正常、缓存、降级和非交易时段; +- 禁止表达、敏感信息脱敏、来源引用和最终持久化。 + +统一验收命令: + +```powershell +python -m pytest -q -p no:cacheprovider +python -m ruff check app tests tools alembic +python -m mypy app +python tools/check_authoritative_docs.py +python tools/audit_schema.py +``` + +## 14. 当前状态 + +公共行情工具 `query_fund_quote` 已注册并可供业务 Agent 使用,但业务 Agent 必须完成自己的代码声明、 +意图白名单配置和注册后才能调用。当前没有注册的业务 Agent 不能直接提交运行。 + +推荐阅读顺序:本文 → 《05-接口文档.md》→ 对应业务域流程文档。本文不替代接口文档和数据库基线。 diff --git a/tests/contract/test_fund_quote_tool_contract.py b/tests/contract/test_fund_quote_tool_contract.py new file mode 100644 index 0000000..7b365ca --- /dev/null +++ b/tests/contract/test_fund_quote_tool_contract.py @@ -0,0 +1,10 @@ +from app.service.agent.bootstrap import get_agent_factory + + +def test_public_fund_quote_tool_is_registered_as_read_only() -> None: + factory = get_agent_factory() + tool = factory._tool_executor.registry.get("query_fund_quote") + assert tool.read_only is True + assert tool.required_permission == "fund:quote:read" + assert "advisor" in tool.allowed_roles + assert "risk_operator" in tool.allowed_roles diff --git a/tests/unit/infrastructure/test_fund_market_adapter.py b/tests/unit/infrastructure/test_fund_market_adapter.py new file mode 100644 index 0000000..55445e6 --- /dev/null +++ b/tests/unit/infrastructure/test_fund_market_adapter.py @@ -0,0 +1,45 @@ +from datetime import date +from decimal import Decimal + +import httpx +import pytest + +from app.infrastructure.fund_market_adapter import EastmoneyFundAdapter + + +@pytest.mark.asyncio +async def test_adapter_parses_names_quotes_and_history() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if "pingzhongdata" in str(request.url): + return httpx.Response(200, text='var fS_name = "南方测试基金";') + if "ulist.np" in str(request.url): + return httpx.Response(200, json={"data": {"diff": [{ + "f12": "159511", "f2": "1.23", "f3": "0.82" + }]}}) + return httpx.Response(200, json={"Data": {"LSJZList": [{ + "FSRQ": "2026-09-09", "DWJZ": "1.20", "JZZZL": "0.10" + }]}}) + + adapter = EastmoneyFundAdapter(client=httpx.AsyncClient(transport=httpx.MockTransport(handler))) + assert await adapter.fetch_names(["159511"]) == {"159511": "南方测试基金"} + assert (await adapter.fetch_quotes(["159511"]))["159511"]["nav"] == Decimal("1.23") + history = await adapter.fetch_history("159511", date(2026, 9, 9)) + assert history["nav"] == Decimal("1.20") + await adapter._client.aclose() # type: ignore[union-attr] + + +@pytest.mark.asyncio +async def test_adapter_retries_then_returns_degraded_history() -> None: + calls = 0 + + def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(503) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + adapter = EastmoneyFundAdapter(client=client, retries=1) + result = await adapter.fetch_history("159511", date(2026, 9, 9)) + assert result == {"degraded": True} + assert calls == 2 + await client.aclose() diff --git a/tests/unit/service/test_fund_quote_cache.py b/tests/unit/service/test_fund_quote_cache.py new file mode 100644 index 0000000..1374b2c --- /dev/null +++ b/tests/unit/service/test_fund_quote_cache.py @@ -0,0 +1,53 @@ +from datetime import UTC, datetime +from decimal import Decimal + +import pytest + +from app.core.fund_contracts import FundQuoteQuery +from app.infrastructure.fund_quote_cache import FundQuoteCache +from app.infrastructure.memory_cache import CacheReadResult +from app.service.fund_quote_service import FundQuoteService + + +class MemoryClient: + def __init__(self) -> None: + self.values: dict[str, str] = {} + self.ttls: dict[str, int] = {} + + async def get(self, key: str) -> CacheReadResult: + return CacheReadResult(self.values.get(key)) + + async def set(self, key: str, value: str, ttl_seconds: int) -> bool: + self.values[key] = value + self.ttls[key] = ttl_seconds + return True + + +class Adapter: + calls = 0 + + async def fetch_names(self, codes: list[str]) -> dict[str, str]: + self.calls += 1 + return {code: "测试基金" for code in codes} + + async def fetch_quotes(self, codes: list[str]) -> dict[str, dict[str, object]]: + self.calls += 1 + return {code: {"nav": Decimal("1.2")} for code in codes} + + async def fetch_history(self, code: str, target_date: object) -> dict[str, object]: + self.calls += 1 + return {"nav": Decimal("1.1"), "nav_date": target_date, "degraded": False} + + +@pytest.mark.asyncio +async def test_service_reads_and_writes_cache() -> None: + client = MemoryClient() + adapter = Adapter() + service = FundQuoteService(adapter, FundQuoteCache(client)) + query = FundQuoteQuery(fund_codes=("159511",)) + first = await service.query(query, now=datetime(2026, 9, 9, 8, tzinfo=UTC)) + second = await service.query(query, now=datetime(2026, 9, 9, 8, tzinfo=UTC)) + assert first[0].quote_source == "eastmoney" + assert second[0].quote_source == "cache" + assert adapter.calls == 2 + assert next(iter(client.ttls.values())) == 900 diff --git a/tests/unit/service/test_fund_quote_config.py b/tests/unit/service/test_fund_quote_config.py new file mode 100644 index 0000000..6f3d70d --- /dev/null +++ b/tests/unit/service/test_fund_quote_config.py @@ -0,0 +1,23 @@ +from app.service.fund_quote_service import ( + FUND_TYPE_GROUPS, + FundQuoteRuntimeConfig, +) + + +def test_invalid_runtime_config_uses_safe_defaults() -> None: + config = FundQuoteRuntimeConfig.from_mapping({ + "allowed_codes": ["bad", "159511"], + "intraday_cache_ttl": -1, + "closing_cache_ttl": "900", + }) + assert config.allowed_codes == ("159511",) + assert config.intraday_cache_ttl == 60 + assert config.closing_cache_ttl == 900 + + +def test_empty_code_list_does_not_disable_market_data() -> None: + config = FundQuoteRuntimeConfig.from_mapping({"allowed_codes": []}) + assert config.allowed_codes + assert set(config.allowed_codes) == { + code for codes in FUND_TYPE_GROUPS.values() for code in codes + } diff --git a/tests/unit/service/test_fund_quote_service.py b/tests/unit/service/test_fund_quote_service.py new file mode 100644 index 0000000..ad069ac --- /dev/null +++ b/tests/unit/service/test_fund_quote_service.py @@ -0,0 +1,68 @@ +from datetime import UTC, datetime +from decimal import Decimal + +import pytest + +from app.core.fund_contracts import FundQuoteQuery +from app.service.fund_quote_service import FundQuoteService + + +class StubAdapter: + async def fetch_names(self, codes: list[str]) -> dict[str, str]: + return {code: f"基金-{code}" for code in codes} + + async def fetch_quotes(self, codes: list[str]) -> dict[str, dict[str, object]]: + return {code: {"nav": Decimal("1.23"), "daily_change": Decimal("0.82")} for code in codes} + + async def fetch_history(self, code: str, target_date: object) -> dict[str, object]: + return { + "nav": Decimal("1.20"), "nav_date": target_date, + "daily_change": Decimal("0.10"), "degraded": False, + } + + +@pytest.mark.asyncio +async def test_service_uses_intraday_quote_during_market_hours() -> None: + service = FundQuoteService(StubAdapter()) + result = await service.query( + FundQuoteQuery(fund_codes=("159511",)), + now=datetime(2026, 9, 9, 2, 0, tzinfo=UTC), + ) + assert result[0].nav == Decimal("1.23") + assert result[0].is_intraday is True + assert result[0].degraded is False + + +@pytest.mark.asyncio +async def test_service_uses_history_outside_market_hours() -> None: + service = FundQuoteService(StubAdapter()) + result = await service.query( + FundQuoteQuery(fund_codes=("159511",)), + now=datetime(2026, 9, 9, 8, 0, tzinfo=UTC), + ) + assert result[0].nav == Decimal("1.20") + assert result[0].is_intraday is False + + +@pytest.mark.asyncio +async def test_service_filters_type_and_rejects_unknown_type() -> None: + service = FundQuoteService(StubAdapter()) + result = await service.query(FundQuoteQuery(fund_type="债券型", limit=1)) + assert len(result) == 1 + assert result[0].fund_type == "债券型" + with pytest.raises(ValueError, match="基金类型"): + await service.query(FundQuoteQuery(fund_type="未知")) + + +@pytest.mark.asyncio +async def test_intraday_missing_live_quote_is_degraded() -> None: + class NoLive(StubAdapter): + async def fetch_quotes(self, codes: list[str]) -> dict[str, dict[str, object]]: + return {} + + result = await FundQuoteService(NoLive()).query( + FundQuoteQuery(fund_codes=("159511",)), + now=datetime(2026, 9, 9, 2, 0, tzinfo=UTC), + ) + assert result[0].degraded is True + assert result[0].quote_source == "degraded" diff --git a/tests/unit/service/test_fund_quote_tool.py b/tests/unit/service/test_fund_quote_tool.py new file mode 100644 index 0000000..96a2944 --- /dev/null +++ b/tests/unit/service/test_fund_quote_tool.py @@ -0,0 +1,41 @@ +from datetime import date +from decimal import Decimal + +import pytest + +from app.core.contracts import RequestContext +from app.core.fund_contracts import FundQuote +from app.service import fund_quote_service +from app.service.fund_quote_service import query_fund_quote_tool + + +@pytest.mark.asyncio +async def test_query_fund_quote_tool_returns_stable_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class StubService: + async def query(self, query: object) -> list[FundQuote]: + return [FundQuote( + fund_code="159511", fund_name="测试基金", fund_type="股票型", + nav=Decimal("1.20"), nav_date=date(2026, 9, 9), + quote_time="2026-09-09T10:00:00+08:00", + quote_source="eastmoney", is_intraday=True, + )] + + class Factory: + @staticmethod + def create() -> object: + return object() + + monkeypatch.setattr(fund_quote_service.EastmoneyAdapterFactory, "create", Factory.create) + monkeypatch.setattr( + fund_quote_service, + "FundQuoteService", + lambda _adapter, **_kwargs: StubService(), + ) + result = await query_fund_quote_tool( + fund_quote_service.FundQuoteQuery(fund_codes=("159511",)), + RequestContext(user_id="1", trace_id="quote"), + ) + assert result[0]["fund_code"] == "159511" + assert result[0]["nav"] == "1.20" diff --git a/tests/unit/service/test_health_service.py b/tests/unit/service/test_health_service.py new file mode 100644 index 0000000..4d06246 --- /dev/null +++ b/tests/unit/service/test_health_service.py @@ -0,0 +1,15 @@ +import pytest + +from app.service.health_service import HealthService + + +@pytest.mark.asyncio +async def test_redis_failure_marks_readiness_degraded(monkeypatch: pytest.MonkeyPatch) -> None: + async def unavailable(self: HealthService) -> bool: + return False + + monkeypatch.setattr(HealthService, "_redis_ready", unavailable) + service = HealthService() + result = await service.ready() + assert result["checks"]["redis"] is False + assert result["status"] == "degraded"