feat: Phase 5 — AI Research Agent(受控工具白名单 + LLM 编排 + API)

- agent/tools.py:Tool 元数据(JSON Schema)+ 白名单调用(异常转可读反馈,不中断对话)
- agent/tools_impl.py:6 个受控工具 search_stocks / get_market_data / test_factor / run_backtest / get_experiment / compare_experiments —— 全部只读经 Job/Experiment 链路,研究自动归档;无 shell/任意执行/写删数据能力
- agent/llm.py:LLMClient 抽象 + OpenAI 兼容客户端(LLM_API_KEY/LLM_BASE_URL/LLM_MODEL 走 .env,未配置给出引导提示)+ 研究纪律 system prompt(反过拟合/样本外/成本)
- agent/service.py:编排循环(tool/final JSON 决策 → 执行 → 回喂 → 结论),轮次上限兜底,未知工具拒绝
- /api/agent/chat;httpx 移至主依赖;Job 默认工厂抽取(api/agent/executor 复用)
- 测试 6 项(白名单无 shell、完整研究循环产出、未知工具拒绝、轮次兜底),全量 79 passed / ruff clean
This commit is contained in:
Simon
2026-09-06 17:22:05 +08:00
parent 0ea229d766
commit d9be75a98f
14 changed files with 722 additions and 23 deletions
+40
View File
@@ -0,0 +1,40 @@
"""AI Research Agent API(Phase 5)。
POST /api/agent/chat {message} → {reply, actions:[{tool,args,output}]}
未配置 LLM Key 时返回 400 引导(不会崩溃)。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.agent.llm import LLMClient, build_llm_from_settings
from app.agent.service import AgentService
router = APIRouter(prefix="/agent", tags=["agent"])
class AgentChatRequest(BaseModel):
message: str = Field(min_length=1, max_length=2000)
def _llm_or_raise() -> LLMClient:
llm = build_llm_from_settings()
if llm is None:
raise HTTPException(
status_code=400,
detail="未配置 LLM:请在根目录 .env 中设置 LLM_API_KEY(可选 LLM_BASE_URL / LLM_MODEL),"
"参考 .env.example 与 AGENT.md §33",
)
return llm
@router.post("/chat", summary="与 AI 研究助手对话(受控工具)")
def agent_chat(
body: AgentChatRequest,
llm: Annotated[LLMClient, Depends(_llm_or_raise)],
) -> dict:
return AgentService(llm).chat(body.message)
+4 -14
View File
@@ -14,7 +14,7 @@ from datetime import datetime
from fastapi import APIRouter, BackgroundTasks, HTTPException
from fastapi.responses import StreamingResponse
from app.api.deps import DbSession, JobRepoDep, _engine_factory
from app.api.deps import DbSession, JobRepoDep
from app.application.services.job_executor import execute_job, new_id
from app.domain.entities.research import (
BacktestResult,
@@ -24,27 +24,17 @@ from app.domain.entities.research import (
ResearchSpec,
)
from app.infrastructure.persistence.sqlalchemy.repositories.jobs_impl import (
SqlAlchemyExperimentRepository,
SqlAlchemyJobRepository,
)
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
SqlAlchemyDailyBarRepository,
SqlAlchemyStockRepository,
)
from app.infrastructure.persistence.sqlalchemy.session import SessionLocal
router = APIRouter(prefix="/jobs", tags=["jobs"])
def _bg_factories():
return {
"session_factory": SessionLocal,
"job_repo_factory": lambda s: SqlAlchemyJobRepository(s),
"experiment_repo_factory": lambda s: SqlAlchemyExperimentRepository(s),
"stock_repo_factory": lambda s: SqlAlchemyStockRepository(s),
"daily_repo_factory": lambda s: SqlAlchemyDailyBarRepository(s),
"engine": _engine_factory(),
}
from app.application.services.job_executor import default_factories
return default_factories()
def _decode_result(kind: str, result_json: str | None):
+2 -1
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from fastapi import APIRouter
from app.api import experiments, factors, health, jobs, research, stocks
from app.api import agent, experiments, factors, health, jobs, research, stocks
api_router = APIRouter()
api_router.include_router(health.router)
@@ -17,3 +17,4 @@ api_router.include_router(factors.router)
api_router.include_router(research.router)
api_router.include_router(jobs.router)
api_router.include_router(experiments.router)
api_router.include_router(agent.router)