diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b46fa01..a486e5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,5 +83,8 @@ jobs: - name: Lint web run: npm run lint + - name: Test web + run: npm test + - name: Build web run: npm run build diff --git a/AGENTS.md b/AGENTS.md index fcae906..4e51a28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,7 @@ python -m py_compile cd apps/finagent-web npm ci npm run lint +npm test npm run build cd ../finagent-desktop @@ -137,7 +138,7 @@ gh run view --log-failed | `ai-governance` | `.github/workflows/ci.yml` | 校验 `AGENTS.md` / `CLAUDE.md` / `.github` 指令 / `.claude/skills` 关系 | 是 | | `backend-gate` | `.github/workflows/ci.yml` | 执行 `./scripts/ci_gate.sh` | 是 | | `docker-build` | `.github/workflows/ci.yml` | Docker 构建与关键模块导入 smoke | 是 | -| `web-gate` | `.github/workflows/ci.yml` | 前端改动时执行 `npm run lint` + `npm run build` | 是(触发时) | +| `web-gate` | `.github/workflows/ci.yml` | 前端改动时执行 `npm run lint` + `npm test` + `npm run build` | 是(触发时) | | `network-smoke` | `.github/workflows/network-smoke.yml` | `pytest -m network` + `test.sh quick` | 否,观测项 | | `pr-review` | `.github/workflows/pr-review.yml` | PR 静态检查 + AI 审查 + 自动标签 | 否,辅助项 | @@ -153,7 +154,7 @@ gh run view --log-failed - Web 前端改动: - 适用范围:`apps/finagent-web/` - - 默认执行:`cd apps/finagent-web && npm ci && npm run lint && npm run build` + - 默认执行:`cd apps/finagent-web && npm ci && npm run lint && npm test && npm run build` - 若涉及 API 联调、路由、状态管理、Markdown/图表渲染或认证状态,交付说明中要明确说明联动面和未覆盖风险。 - 桌面端改动: diff --git a/api/v1/schemas/analysis.py b/api/v1/schemas/analysis.py index 219e034..7e94a64 100644 --- a/api/v1/schemas/analysis.py +++ b/api/v1/schemas/analysis.py @@ -13,7 +13,7 @@ from typing import Optional, List, Any from enum import Enum -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from src.utils.analysis_metadata import SELECTION_SOURCE_PATTERN @@ -27,16 +27,31 @@ class TaskStatusEnum(str, Enum): class AnalyzeRequest(BaseModel): """Analysis request parameters""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "stock_code": "600519", + "report_type": "detailed", + "force_refresh": False, + "async_mode": False, + "stock_name": "贵州茅台", + "original_query": "茅台", + "selection_source": "autocomplete", + "notify": True, + } + } + ) stock_code: Optional[str] = Field( None, description="单只股票代码", - example="600519" + json_schema_extra={"example": "600519"}, ) stock_codes: Optional[List[str]] = Field( None, description="多只股票代码(与 stock_code 二选一)", - example=["600519", "000858"] + json_schema_extra={"example": ["600519", "000858"]}, ) report_type: str = Field( "detailed", @@ -54,50 +69,30 @@ class AnalyzeRequest(BaseModel): stock_name: Optional[str] = Field( None, description="用户选中的股票名称(自动补全时提供)", - example="贵州茅台" + json_schema_extra={"example": "贵州茅台"}, ) original_query: Optional[str] = Field( None, description="用户原始输入(如茅台、gzmt、600519)", - example="茅台" + json_schema_extra={"example": "茅台"}, ) selection_source: Optional[str] = Field( None, description="股票选择来源:manual(手动输入) | autocomplete(自动补全) | import(导入) | image(图片识别)", pattern=SELECTION_SOURCE_PATTERN, - example="autocomplete" + json_schema_extra={"example": "autocomplete"}, ) notify: bool = Field( True, description="是否发送推送通知(Telegram/企业微信等)" ) - class Config: - json_schema_extra = { - "example": { - "stock_code": "600519", - "report_type": "detailed", - "force_refresh": False, - "async_mode": False, - "stock_name": "贵州茅台", - "original_query": "茅台", - "selection_source": "autocomplete", - "notify": True - } - } - class AnalysisResultResponse(BaseModel): """分析结果响应模型""" - - query_id: str = Field(..., description="分析记录唯一标识") - stock_code: str = Field(..., description="股票代码") - stock_name: Optional[str] = Field(None, description="股票名称") - report: Optional[Any] = Field(None, description="分析报告") - created_at: str = Field(..., description="创建时间") - - class Config: - json_schema_extra = { + + model_config = ConfigDict( + json_schema_extra={ "example": { "query_id": "abc123def456", "stock_code": "600519", @@ -105,16 +100,33 @@ class Config: "report": { "summary": { "sentiment_score": 75, - "operation_advice": "持有" + "operation_advice": "持有", } }, - "created_at": "2024-01-01T12:00:00" + "created_at": "2024-01-01T12:00:00", } } + ) + + query_id: str = Field(..., description="分析记录唯一标识") + stock_code: str = Field(..., description="股票代码") + stock_name: Optional[str] = Field(None, description="股票名称") + report: Optional[Any] = Field(None, description="分析报告") + created_at: str = Field(..., description="创建时间") class TaskAccepted(BaseModel): """异步任务接受响应""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "task_id": "task_abc123", + "status": "pending", + "message": "Analysis task accepted", + } + } + ) task_id: str = Field(..., description="任务 ID,用于查询状态") status: str = Field( @@ -124,18 +136,20 @@ class TaskAccepted(BaseModel): ) message: Optional[str] = Field(None, description="提示信息") - class Config: - json_schema_extra = { + +class BatchTaskAcceptedItem(BaseModel): + """批量异步任务中的单个成功提交项。""" + + model_config = ConfigDict( + json_schema_extra={ "example": { "task_id": "task_abc123", + "stock_code": "600519", "status": "pending", - "message": "Analysis task accepted" + "message": "分析任务已加入队列: 600519", } } - - -class BatchTaskAcceptedItem(BaseModel): - """批量异步任务中的单个成功提交项。""" + ) task_id: str = Field(..., description="任务 ID,用于查询状态") stock_code: str = Field(..., description="股票代码") @@ -146,66 +160,73 @@ class BatchTaskAcceptedItem(BaseModel): ) message: Optional[str] = Field(None, description="提示信息") - class Config: - json_schema_extra = { - "example": { - "task_id": "task_abc123", - "stock_code": "600519", - "status": "pending", - "message": "分析任务已加入队列: 600519" - } - } - class BatchDuplicateTaskItem(BaseModel): """批量异步任务中的重复提交项。""" - stock_code: str = Field(..., description="股票代码") - existing_task_id: str = Field(..., description="已存在的任务 ID") - message: str = Field(..., description="错误信息") - - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "stock_code": "600519", "existing_task_id": "task_existing_123", - "message": "股票 600519 正在分析中 (task_id: task_existing_123)" + "message": "股票 600519 正在分析中 (task_id: task_existing_123)", } } + ) + + stock_code: str = Field(..., description="股票代码") + existing_task_id: str = Field(..., description="已存在的任务 ID") + message: str = Field(..., description="错误信息") class BatchTaskAcceptedResponse(BaseModel): """批量异步任务接受响应。""" - accepted: List[BatchTaskAcceptedItem] = Field(default_factory=list, description="成功提交的任务列表") - duplicates: List[BatchDuplicateTaskItem] = Field(default_factory=list, description="重复而跳过的任务列表") - message: str = Field(..., description="汇总信息") - - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "accepted": [ { "task_id": "task_abc123", "stock_code": "600519", "status": "pending", - "message": "分析任务已加入队列: 600519" + "message": "分析任务已加入队列: 600519", } ], "duplicates": [ { "stock_code": "000858", "existing_task_id": "task_existing_456", - "message": "股票 000858 正在分析中 (task_id: task_existing_456)" + "message": "股票 000858 正在分析中 (task_id: task_existing_456)", } ], - "message": "已提交 1 个任务,1 个重复跳过" + "message": "已提交 1 个任务,1 个重复跳过", } } + ) + + accepted: List[BatchTaskAcceptedItem] = Field(default_factory=list, description="成功提交的任务列表") + duplicates: List[BatchDuplicateTaskItem] = Field(default_factory=list, description="重复而跳过的任务列表") + message: str = Field(..., description="汇总信息") class TaskStatus(BaseModel): """Task status model""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "task_id": "task_abc123", + "status": "completed", + "progress": 100, + "result": None, + "error": None, + "stock_name": "贵州茅台", + "original_query": "茅台", + "selection_source": "autocomplete", + } + } + ) task_id: str = Field(..., description="任务 ID") status: str = Field( @@ -235,23 +256,29 @@ class TaskStatus(BaseModel): pattern=SELECTION_SOURCE_PATTERN, ) - class Config: - json_schema_extra = { + +class TaskInfo(BaseModel): + """ + + model_config = ConfigDict( + json_schema_extra={ "example": { - "task_id": "task_abc123", - "status": "completed", - "progress": 100, - "result": None, - "error": None, + "task_id": "abc123def456", + "stock_code": "600519", "stock_name": "贵州茅台", + "status": "processing", + "progress": 50, + "message": "正在分析中...", + "report_type": "detailed", + "created_at": "2026-02-05T10:30:00", + "started_at": "2026-02-05T10:30:01", + "completed_at": None, + "error": None, "original_query": "茅台", - "selection_source": "autocomplete" + "selection_source": "autocomplete", } } - - -class TaskInfo(BaseModel): - """ + ) Task details model Used for task list and SSE event delivery @@ -275,59 +302,42 @@ class TaskInfo(BaseModel): pattern=SELECTION_SOURCE_PATTERN, ) - class Config: - json_schema_extra = { - "example": { - "task_id": "abc123def456", - "stock_code": "600519", - "stock_name": "贵州茅台", - "status": "processing", - "progress": 50, - "message": "正在分析中...", - "report_type": "detailed", - "created_at": "2026-02-05T10:30:00", - "started_at": "2026-02-05T10:30:01", - "completed_at": None, - "error": None, - "original_query": "茅台", - "selection_source": "autocomplete" - } - } - class TaskListResponse(BaseModel): """任务列表响应模型""" - - total: int = Field(..., description="任务总数") - pending: int = Field(..., description="等待中的任务数") - processing: int = Field(..., description="处理中的任务数") - tasks: List[TaskInfo] = Field(..., description="任务列表") - - class Config: - json_schema_extra = { + + model_config = ConfigDict( + json_schema_extra={ "example": { "total": 3, "pending": 1, "processing": 2, - "tasks": [] + "tasks": [], } } + ) + + total: int = Field(..., description="任务总数") + pending: int = Field(..., description="等待中的任务数") + processing: int = Field(..., description="处理中的任务数") + tasks: List[TaskInfo] = Field(..., description="任务列表") class DuplicateTaskErrorResponse(BaseModel): """重复任务错误响应模型""" - - error: str = Field("duplicate_task", description="错误类型") - message: str = Field(..., description="错误信息") - stock_code: str = Field(..., description="股票代码") - existing_task_id: str = Field(..., description="已存在的任务 ID") - - class Config: - json_schema_extra = { + + model_config = ConfigDict( + json_schema_extra={ "example": { "error": "duplicate_task", "message": "股票 600519 正在分析中", "stock_code": "600519", - "existing_task_id": "abc123def456" + "existing_task_id": "abc123def456", } } + ) + + error: str = Field("duplicate_task", description="错误类型") + message: str = Field(..., description="错误信息") + stock_code: str = Field(..., description="股票代码") + existing_task_id: str = Field(..., description="已存在的任务 ID") diff --git a/api/v1/schemas/common.py b/api/v1/schemas/common.py index 8b3fc90..d54c208 100644 --- a/api/v1/schemas/common.py +++ b/api/v1/schemas/common.py @@ -11,68 +11,92 @@ from typing import Optional, Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class RootResponse(BaseModel): """API 根路由响应""" - message: str = Field(..., description="API 运行状态消息", example="Daily Stock Analysis API is running") - version: Optional[str] = Field(None, description="API 版本", example="1.0.0") - - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "message": "Daily Stock Analysis API is running", - "version": "1.0.0" + "version": "1.0.0", } } + ) + + message: str = Field( + ..., + description="API 运行状态消息", + json_schema_extra={"example": "Daily Stock Analysis API is running"}, + ) + version: Optional[str] = Field( + None, + description="API 版本", + json_schema_extra={"example": "1.0.0"}, + ) class HealthResponse(BaseModel): """健康检查响应""" - status: str = Field(..., description="服务状态", example="ok") - timestamp: Optional[str] = Field(None, description="时间戳") - - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "status": "ok", - "timestamp": "2024-01-01T12:00:00" + "timestamp": "2024-01-01T12:00:00", } } + ) + + status: str = Field( + ..., + description="服务状态", + json_schema_extra={"example": "ok"}, + ) + timestamp: Optional[str] = Field(None, description="时间戳") class ErrorResponse(BaseModel): """错误响应""" - error: str = Field(..., description="错误类型", example="validation_error") - message: str = Field(..., description="错误详情", example="请求参数错误") - detail: Optional[Any] = Field(None, description="附加错误信息") - - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "error": "not_found", "message": "资源不存在", - "detail": None + "detail": None, } } + ) + + error: str = Field( + ..., + description="错误类型", + json_schema_extra={"example": "validation_error"}, + ) + message: str = Field( + ..., + description="错误详情", + json_schema_extra={"example": "请求参数错误"}, + ) + detail: Optional[Any] = Field(None, description="附加错误信息") class SuccessResponse(BaseModel): """通用成功响应""" - success: bool = Field(True, description="是否成功") - message: Optional[str] = Field(None, description="成功消息") - data: Optional[Any] = Field(None, description="响应数据") - - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "success": True, "message": "操作成功", - "data": None + "data": None, } } + ) + + success: bool = Field(True, description="是否成功") + message: Optional[str] = Field(None, description="成功消息") + data: Optional[Any] = Field(None, description="响应数据") diff --git a/api/v1/schemas/history.py b/api/v1/schemas/history.py index 6941702..717343d 100644 --- a/api/v1/schemas/history.py +++ b/api/v1/schemas/history.py @@ -17,6 +17,21 @@ class HistoryItem(BaseModel): """历史记录摘要(列表展示用)""" + model_config = ConfigDict( + json_schema_extra={ + "example": { + "id": 1234, + "query_id": "abc123", + "stock_code": "600519", + "stock_name": "贵州茅台", + "report_type": "detailed", + "sentiment_score": 75, + "operation_advice": "持有", + "created_at": "2024-01-01T12:00:00", + } + } + ) + id: Optional[int] = Field(None, description="分析历史记录主键 ID") query_id: str = Field(..., description="分析记录关联 query_id(批量分析时重复)") stock_code: str = Field(..., description="股票代码") @@ -29,38 +44,25 @@ class HistoryItem(BaseModel): operation_advice: Optional[str] = Field(None, description="操作建议") created_at: Optional[str] = Field(None, description="创建时间") - class Config: - json_schema_extra = { - "example": { - "id": 1234, - "query_id": "abc123", - "stock_code": "600519", - "stock_name": "贵州茅台", - "report_type": "detailed", - "sentiment_score": 75, - "operation_advice": "持有", - "created_at": "2024-01-01T12:00:00" - } - } - class HistoryListResponse(BaseModel): """历史记录列表响应""" - - total: int = Field(..., description="总记录数") - page: int = Field(..., description="当前页码") - limit: int = Field(..., description="每页数量") - items: List[HistoryItem] = Field(default_factory=list, description="记录列表") - - class Config: - json_schema_extra = { + + model_config = ConfigDict( + json_schema_extra={ "example": { "total": 100, "page": 1, "limit": 20, - "items": [] + "items": [], } } + ) + + total: int = Field(..., description="总记录数") + page: int = Field(..., description="当前页码") + limit: int = Field(..., description="每页数量") + items: List[HistoryItem] = Field(default_factory=list, description="记录列表") class DeleteHistoryRequest(BaseModel): @@ -78,33 +80,35 @@ class DeleteHistoryResponse(BaseModel): class NewsIntelItem(BaseModel): """新闻情报条目""" - title: str = Field(..., description="新闻标题") - snippet: str = Field("", description="新闻摘要(最多200字)") - url: str = Field(..., description="新闻链接") - - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "title": "公司发布业绩快报,营收同比增长 20%", "snippet": "公司公告显示,季度营收同比增长 20%...", - "url": "https://example.com/news/123" + "url": "https://example.com/news/123", } } + ) + + title: str = Field(..., description="新闻标题") + snippet: str = Field("", description="新闻摘要(最多200字)") + url: str = Field(..., description="新闻链接") class NewsIntelResponse(BaseModel): """新闻情报响应""" - total: int = Field(..., description="新闻条数") - items: List[NewsIntelItem] = Field(default_factory=list, description="新闻列表") - - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "total": 2, - "items": [] + "items": [], } } + ) + + total: int = Field(..., description="新闻条数") + items: List[NewsIntelItem] = Field(default_factory=list, description="新闻列表") class ReportMeta(BaseModel): @@ -161,13 +165,8 @@ class ReportDetails(BaseModel): class AnalysisReport(BaseModel): """完整分析报告""" - meta: ReportMeta = Field(..., description="元信息") - summary: ReportSummary = Field(..., description="概览区") - strategy: Optional[ReportStrategy] = Field(None, description="策略点位区") - details: Optional[ReportDetails] = Field(None, description="详情区") - - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "meta": { "query_id": "abc123", @@ -175,34 +174,41 @@ class Config: "stock_name": "贵州茅台", "report_type": "detailed", "report_language": "zh", - "created_at": "2024-01-01T12:00:00" + "created_at": "2024-01-01T12:00:00", }, "summary": { "analysis_summary": "技术面向好,建议持有", "operation_advice": "持有", "trend_prediction": "看多", "sentiment_score": 75, - "sentiment_label": "乐观" + "sentiment_label": "乐观", }, "strategy": { "ideal_buy": "1800.00", "secondary_buy": "1750.00", "stop_loss": "1700.00", - "take_profit": "2000.00" + "take_profit": "2000.00", }, - "details": None + "details": None, } } + ) + + meta: ReportMeta = Field(..., description="元信息") + summary: ReportSummary = Field(..., description="概览区") + strategy: Optional[ReportStrategy] = Field(None, description="策略点位区") + details: Optional[ReportDetails] = Field(None, description="详情区") class MarkdownReportResponse(BaseModel): """Markdown 格式报告响应""" - content: str = Field(..., description="Markdown 格式的完整报告内容") - - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { - "content": "# 📊 贵州茅台 (600519) 分析报告\n\n> 分析日期:**2024-01-01**\n\n..." + "content": "# 📊 贵州茅台 (600519) 分析报告\n\n> 分析日期:**2024-01-01**\n\n...", } } + ) + + content: str = Field(..., description="Markdown 格式的完整报告内容") diff --git a/api/v1/schemas/screener.py b/api/v1/schemas/screener.py index 74bbfbf..b35891d 100644 --- a/api/v1/schemas/screener.py +++ b/api/v1/schemas/screener.py @@ -23,13 +23,16 @@ class ScreenerScreenRequest(BaseModel): analyze_after_screen: bool = Field(default=False, description="筛选后是否进行分析") -class TaskAccepted(BaseModel): +class ScreenerTaskAccepted(BaseModel): """任务已接受响应""" task_id: str = Field(..., description="任务ID") status: str = Field(default="accepted", description="状态") message: str = Field(default="筛选任务已接受", description="消息") +TaskAccepted = ScreenerTaskAccepted + + class ScreenerResultResponse(BaseModel): """筛选结果响应""" task_id: str = Field(..., description="任务ID") diff --git a/api/v1/schemas/stocks.py b/api/v1/schemas/stocks.py index 742e1cb..35b5f70 100644 --- a/api/v1/schemas/stocks.py +++ b/api/v1/schemas/stocks.py @@ -11,12 +11,31 @@ from typing import Optional, List -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class StockQuote(BaseModel): """股票实时行情""" - + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "stock_code": "600519", + "stock_name": "贵州茅台", + "current_price": 1800.00, + "change": 15.00, + "change_percent": 0.84, + "open": 1785.00, + "high": 1810.00, + "low": 1780.00, + "prev_close": 1785.00, + "volume": 10000000, + "amount": 18000000000, + "update_time": "2024-01-01T15:00:00", + } + } + ) + stock_code: str = Field(..., description="股票代码") stock_name: Optional[str] = Field(None, description="股票名称") current_price: float = Field(..., description="当前价格") @@ -30,27 +49,24 @@ class StockQuote(BaseModel): amount: Optional[float] = Field(None, description="成交额(元)") update_time: Optional[str] = Field(None, description="更新时间") - class Config: - json_schema_extra = { + +class KLineData(BaseModel): + """K 线数据点""" + + model_config = ConfigDict( + json_schema_extra={ "example": { - "stock_code": "600519", - "stock_name": "贵州茅台", - "current_price": 1800.00, - "change": 15.00, - "change_percent": 0.84, + "date": "2024-01-01", "open": 1785.00, "high": 1810.00, "low": 1780.00, - "prev_close": 1785.00, + "close": 1800.00, "volume": 10000000, "amount": 18000000000, - "update_time": "2024-01-01T15:00:00" + "change_percent": 0.84, } } - - -class KLineData(BaseModel): - """K 线数据点""" + ) date: str = Field(..., description="日期") open: float = Field(..., description="开盘价") @@ -61,20 +77,6 @@ class KLineData(BaseModel): amount: Optional[float] = Field(None, description="成交额") change_percent: Optional[float] = Field(None, description="涨跌幅 (%)") - class Config: - json_schema_extra = { - "example": { - "date": "2024-01-01", - "open": 1785.00, - "high": 1810.00, - "low": 1780.00, - "close": 1800.00, - "volume": 10000000, - "amount": 18000000000, - "change_percent": 0.84 - } - } - class ExtractItem(BaseModel): """单条提取结果(代码、名称、置信度)""" @@ -94,18 +96,19 @@ class ExtractFromImageResponse(BaseModel): class StockHistoryResponse(BaseModel): """股票历史行情响应""" - - stock_code: str = Field(..., description="股票代码") - stock_name: Optional[str] = Field(None, description="股票名称") - period: str = Field(..., description="K 线周期") - data: List[KLineData] = Field(default_factory=list, description="K 线数据列表") - - class Config: - json_schema_extra = { + + model_config = ConfigDict( + json_schema_extra={ "example": { "stock_code": "600519", "stock_name": "贵州茅台", "period": "daily", - "data": [] + "data": [], } } + ) + + stock_code: str = Field(..., description="股票代码") + stock_name: Optional[str] = Field(None, description="股票名称") + period: str = Field(..., description="K 线周期") + data: List[KLineData] = Field(default_factory=list, description="K 线数据列表") diff --git a/api/v1/schemas/system_config.py b/api/v1/schemas/system_config.py index 1a46ed9..ab72284 100644 --- a/api/v1/schemas/system_config.py +++ b/api/v1/schemas/system_config.py @@ -142,6 +142,8 @@ class ValidateSystemConfigResponse(BaseModel): class TestLLMChannelRequest(BaseModel): """Request payload for testing one LLM channel.""" + __test__ = False + name: str = "channel" protocol: str = "openai" base_url: str = "" diff --git a/apps/finagent-web/src/setupTests.ts b/apps/finagent-web/src/setupTests.ts index 25cc13c..b4e809e 100644 --- a/apps/finagent-web/src/setupTests.ts +++ b/apps/finagent-web/src/setupTests.ts @@ -1,4 +1,34 @@ import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +const canvasContext2DMock = { + beginPath: vi.fn(), + arc: vi.fn(), + fill: vi.fn(), + clearRect: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fillStyle: '', + lineWidth: 1, + strokeStyle: '', +}; + +if (typeof HTMLCanvasElement !== 'undefined') { + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + writable: true, + value: vi.fn((contextId: string) => ( + contextId === '2d' ? canvasContext2DMock : null + )), + }); +} + +if (typeof window !== 'undefined') { + Object.defineProperty(window, 'scrollTo', { + writable: true, + value: vi.fn(), + }); +} class IntersectionObserverMock implements IntersectionObserver { readonly root = null; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 00c799d..8dd22f8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -16,7 +16,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [改进] 打板策略结果在 UI 中按连板层级(1进2 / 2进3 / 3进4)分组展示,不同层级使用不同颜色区分 - [新功能] API 客户端新增 `getBoardData` 方法支持直接获取打板策略原始数据 - [chore] 恢复 CI、网络冒烟与 PR 静态检查工作流,并补齐 GitHub Copilot / Coding Agent 协作资产镜像 -- [测试] 新增 AI 资产、CI 工作流与 Changelog `[Unreleased]` 格式守卫,防止协作治理漂移 +- [测试] 新增 AI 资产、CI/PR 工作流、PR 模板与 Changelog `[Unreleased]` 格式守卫,防止协作治理漂移 +- [修复] `newspaper3k` 缺失时搜索服务不再在模块导入阶段失败,正文补抓会返回空内容并让主分析链路继续按无正文降级运行 +- [测试] Discord 平台签名测试在本地缺少可选 `PyNaCl` 依赖时显式跳过,避免离线测试基线在收集阶段中断 +- [修复] Agent runner 在最终回答阶段不再依赖第二次流式调用才能产出内容,避免非流式适配器或测试 mock 返回空结果 +- [测试] 隔离手动环境验证脚本、本地 `.env` 与 SQLite 临时库文件锁对离线 pytest 的影响,并修复 Windows 默认编码读取源码导致的静态测试失败 +- [测试] 注册 pytest `benchmark` 标记并排除 LLM 通道测试请求 Schema 的误收集,减少离线测试基线噪音 +- [chore] API v1 通用、分析、历史与股票 Schema 示例配置迁移到 Pydantic v2 推荐写法,减少测试输出中的弃用告警 +- [测试] Web Vitest 环境补齐 canvas 与 scrollTo 浏览器 API mock,减少 jsdom 噪音并提升前端测试输出可信度 +- [测试] CI `web-gate` 补跑 Vitest,确保前端测试基线和 lint/build 一起作为阻断检查 +- [测试] 过滤 `lark_oapi` 传递依赖触发的 `pkg_resources` 弃用告警,让离线 pytest 基线只暴露项目自身或未处理告警 +- [修复] `backend-gate` 改为通过 `bash test.sh` 调用确定性检查,避免 CI runner 因 `test.sh` 未带可执行位而中断 - [修复] `AGENT_MAX_STEPS` 在 orchestrator 多 Agent 模式下改为作为各子 Agent 的步数上限而非硬覆盖;TechnicalAgent 等高默认值 Agent 会被封顶,低默认值 Agent 保持原值,减少不必要的 LLM 调用膨胀与配额消耗。 - [修复] **MiniMax-M2.7 模型连接测试支持** — 修复 LLM 通道连接测试在 MiniMax-M2.7 模型下返回 "Empty response" 的问题;增加了 `max_tokens` 上限(8→256)以容纳 MiniMax 思考过程,并添加 `content_blocks` 格式解析逻辑统一处理 MiniMax 响应格式差异。 - [修复] 移除 `HistoryItem` 与 `ReportSummary` 响应 Schema 中 `sentiment_score` 的 `ge=0/le=100` 约束(fixes #942)——历史库中存储的超范围负值或大于 100 的情绪评分不再触发 Pydantic ValidationError,历史列表与详情接口恢复正常返回。 diff --git a/scripts/ci_gate.sh b/scripts/ci_gate.sh index bfd85ff..a40c33b 100644 --- a/scripts/ci_gate.sh +++ b/scripts/ci_gate.sh @@ -17,8 +17,8 @@ flake8_checks() { deterministic_checks() { echo "==> backend-gate: local deterministic checks" - ./test.sh code - ./test.sh yfinance + bash test.sh code + bash test.sh yfinance } offline_test_suite() { diff --git a/setup.cfg b/setup.cfg index a2f95d6..807b92d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,11 @@ markers = unit: fast offline unit tests integration: service-level integration tests without external network dependency network: tests requiring external network or third-party services + benchmark: performance-oriented tests that may assert runtime bounds +filterwarnings = + ignore:pkg_resources is deprecated as an API:DeprecationWarning:lark_oapi\.ws\.pb\.google + ignore:Deprecated call to `pkg_resources\.declare_namespace\('lark_oapi.*:DeprecationWarning:lark_oapi\.ws\.pb\.google + ignore:Deprecated call to `pkg_resources\.declare_namespace\('lark_oapi.*:DeprecationWarning:pkg_resources [isort] profile = black diff --git a/src/agent/runner.py b/src/agent/runner.py index 5034a6e..d8e39fe 100644 --- a/src/agent/runner.py +++ b/src/agent/runner.py @@ -570,10 +570,16 @@ def run_agent_loop( if progress_callback: progress_callback({"type": "generating", "step": step + 1, "message": "正在生成最终分析..."}) - # Stream the final answer content chunk-by-chunk via progress_callback - final_content = _stream_final_answer( - messages, tool_decls, llm_adapter, remaining_timeout, progress_callback, - ) + final_content = response.content or "" + if progress_callback: + # Stream chunks for callers that surface live progress. Fall + # back to the already received response if the stream yields no + # content, which keeps non-streaming adapters and tests honest. + streamed_content = _stream_final_answer( + messages, tool_decls, llm_adapter, remaining_timeout, progress_callback, + ) + if streamed_content: + final_content = streamed_content is_error = False return RunLoopResult( diff --git a/src/search_service.py b/src/search_service.py index 3dcf387..4c1c373 100644 --- a/src/search_service.py +++ b/src/search_service.py @@ -23,7 +23,6 @@ from itertools import cycle from urllib.parse import parse_qsl, unquote, urlparse import requests -from newspaper import Article, Config from tenacity import ( retry, stop_after_attempt, @@ -79,6 +78,12 @@ def fetch_url_content(url: str, timeout: int = 5) -> str: """ 获取 URL 网页正文内容 (使用 newspaper3k) """ + try: + from newspaper import Article, Config + except ImportError as e: + logger.debug(f"newspaper3k unavailable; skip fetching article content for {url}: {e}") + return "" + try: # 配置 newspaper3k config = Config() diff --git a/test_env.py b/test_env.py index ac9e03b..656de6c 100644 --- a/test_env.py +++ b/test_env.py @@ -36,6 +36,13 @@ from datetime import datetime, date, timedelta from typing import Optional +if "pytest" in sys.modules: + import pytest + + pytestmark = pytest.mark.skip( + reason="manual environment validation script; run python test_env.py directly" + ) + # 配置日志 logging.basicConfig( level=logging.INFO, @@ -324,9 +331,10 @@ def test_notification(): service = NotificationService() print_section("配置检查") - if service.is_available(): + webhook_url = config.wechat_webhook_url or "" + if webhook_url: print(f" ✓ 企业微信 Webhook 已配置") - webhook_preview = config.wechat_webhook_url[:50] + "..." if len(config.wechat_webhook_url) > 50 else config.wechat_webhook_url + webhook_preview = webhook_url[:50] + "..." if len(webhook_url) > 50 else webhook_url print(f" URL: {webhook_preview}") else: print(f" ✗ 企业微信 Webhook 未配置") diff --git a/tests/test_changelog_unreleased_format.py b/tests/test_changelog_unreleased_format.py index d7b5b4d..8433ba6 100644 --- a/tests/test_changelog_unreleased_format.py +++ b/tests/test_changelog_unreleased_format.py @@ -1,9 +1,11 @@ +import re from pathlib import Path ROOT = Path(__file__).resolve().parents[1] CHANGELOG = ROOT / "docs" / "CHANGELOG.md" ALLOWED_TYPES = {"新功能", "改进", "修复", "文档", "测试", "chore"} +ENTRY_PATTERN = re.compile(r"^- \[(?P[^\]]+)\] .+") def _unreleased_lines() -> list[str]: @@ -23,7 +25,9 @@ def test_unreleased_changelog_uses_flat_allowed_types(): assert entries for entry in entries: - entry_type = entry.split("]", 1)[0].removeprefix("- [") + match = ENTRY_PATTERN.match(entry) + assert match, entry + entry_type = match.group("type") assert entry_type in ALLOWED_TYPES, entry diff --git a/tests/test_config_env_compat.py b/tests/test_config_env_compat.py index 37e8e2a..ecff201 100644 --- a/tests/test_config_env_compat.py +++ b/tests/test_config_env_compat.py @@ -58,12 +58,14 @@ def test_schedule_run_immediately_falls_back_to_legacy_run_immediately( _mock_parse_yaml, _mock_setup_env, ) -> None: - env = { - "RUN_IMMEDIATELY": "false", - } + with tempfile.TemporaryDirectory() as temp_dir: + env = { + "ENV_FILE": str(Path(temp_dir) / ".env"), + "RUN_IMMEDIATELY": "false", + } - with patch.dict(os.environ, env, clear=True): - config = Config._load_from_env() + with patch.dict(os.environ, env, clear=True): + config = Config._load_from_env() self.assertFalse(config.schedule_run_immediately) self.assertFalse(config.run_immediately) @@ -93,12 +95,14 @@ def test_empty_legacy_run_immediately_stays_false_when_schedule_alias_is_unset( _mock_parse_yaml, _mock_setup_env, ) -> None: - env = { - "RUN_IMMEDIATELY": "", - } + with tempfile.TemporaryDirectory() as temp_dir: + env = { + "ENV_FILE": str(Path(temp_dir) / ".env"), + "RUN_IMMEDIATELY": "", + } - with patch.dict(os.environ, env, clear=True): - config = Config._load_from_env() + with patch.dict(os.environ, env, clear=True): + config = Config._load_from_env() self.assertFalse(config.schedule_run_immediately) self.assertFalse(config.run_immediately) diff --git a/tests/test_discord_platform.py b/tests/test_discord_platform.py index b76895c..52ae44f 100644 --- a/tests/test_discord_platform.py +++ b/tests/test_discord_platform.py @@ -4,10 +4,24 @@ from types import SimpleNamespace from unittest.mock import patch -from nacl.signing import SigningKey +import pytest + +try: + from nacl.signing import SigningKey +except ModuleNotFoundError as exc: + if exc.name != "nacl": + raise + SigningKey = None + pytestmark = pytest.mark.skip(reason="PyNaCl is not installed") +else: + pytestmark = [] from bot.models import ChatType -from bot.platforms.discord import DiscordPlatform + +if SigningKey is not None: + from bot.platforms.discord import DiscordPlatform +else: + DiscordPlatform = None def _make_platform(public_key: str) -> DiscordPlatform: diff --git a/tests/test_github_actions_workflow.py b/tests/test_github_actions_workflow.py index 3f380b3..b41987e 100644 --- a/tests/test_github_actions_workflow.py +++ b/tests/test_github_actions_workflow.py @@ -1,10 +1,19 @@ +import json from pathlib import Path ROOT = Path(__file__).resolve().parents[1] DAILY_ANALYSIS_WORKFLOW = ROOT / ".github" / "workflows" / "daily_analysis.yml" CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" +NETWORK_SMOKE_WORKFLOW = ROOT / ".github" / "workflows" / "network-smoke.yml" +PR_REVIEW_WORKFLOW = ROOT / ".github" / "workflows" / "pr-review.yml" README = ROOT / "README.md" +AGENTS = ROOT / "AGENTS.md" +WEB_PACKAGE = ROOT / "apps" / "finagent-web" / "package.json" +PR_TEMPLATE = ROOT / ".github" / "PULL_REQUEST_TEMPLATE.md" +DOCKERFILE = ROOT / "docker" / "Dockerfile" +BACKEND_GATE_SCRIPT = ROOT / "scripts" / "ci_gate.sh" +TEST_SH = ROOT / "test.sh" def test_daily_analysis_workflow_is_present_and_triggerable(): @@ -25,14 +34,66 @@ def test_ci_workflow_matches_documented_required_gates(): assert CI_WORKFLOW.exists() text = CI_WORKFLOW.read_text(encoding="utf-8") + web_scripts = json.loads(WEB_PACKAGE.read_text(encoding="utf-8"))["scripts"] assert "ai-governance" in text assert "backend-gate" in text assert "docker-build" in text assert "web-gate" in text assert "scripts/ci_gate.sh" in text + assert DOCKERFILE.exists() + assert "docker build -f docker/Dockerfile" in text + assert "import api.app; import src.core.pipeline; import data_provider.base" in text assert "npm run lint" in text + assert "npm test" in text assert "npm run build" in text + assert {"lint", "test", "build"} <= set(web_scripts) + + +def test_backend_gate_script_keeps_required_phases(): + assert BACKEND_GATE_SCRIPT.exists() + assert TEST_SH.exists() + + text = BACKEND_GATE_SCRIPT.read_text(encoding="utf-8") + + for required_text in ( + "syntax_check()", + "flake8_checks()", + "deterministic_checks()", + "offline_test_suite()", + "python -m py_compile", + "flake8 . --count --select=E9,F63,F7,F82", + "bash test.sh code", + "bash test.sh yfinance", + 'python -m pytest -m "not network"', + "syntax)", + "flake8)", + "deterministic)", + "offline-tests)", + ): + assert required_text in text + + +def test_network_smoke_workflow_is_non_blocking_and_triggerable(): + assert NETWORK_SMOKE_WORKFLOW.exists() + + text = NETWORK_SMOKE_WORKFLOW.read_text(encoding="utf-8") + + assert "workflow_dispatch:" in text + assert "schedule:" in text + assert "continue-on-error: true" in text + assert "python -m pytest -m network" in text + assert "bash test.sh quick --dry-run --no-notify" in text + + +def test_pr_review_workflow_keeps_static_checks(): + assert PR_REVIEW_WORKFLOW.exists() + + text = PR_REVIEW_WORKFLOW.read_text(encoding="utf-8") + + assert "pull_request:" in text + assert "python scripts/check_ai_assets.py" in text + assert "test -f .github/PULL_REQUEST_TEMPLATE.md" in text def test_readme_ci_badge_points_to_existing_workflow(): @@ -40,3 +101,35 @@ def test_readme_ci_badge_points_to_existing_workflow(): assert "actions/workflows/ci.yml" in readme assert CI_WORKFLOW.exists() + + +def test_agents_ci_matrix_mentions_existing_workflows(): + agents = AGENTS.read_text(encoding="utf-8") + + for required_text in ( + "ai-governance", + "backend-gate", + "docker-build", + "web-gate", + "network-smoke", + "pr-review", + ".github/workflows/ci.yml", + ".github/workflows/network-smoke.yml", + ".github/workflows/pr-review.yml", + "npm test", + ): + assert required_text in agents + + +def test_pr_template_keeps_required_delivery_sections(): + template = PR_TEMPLATE.read_text(encoding="utf-8") + + for heading in ( + "## PR Type", + "## Background And Problem", + "## Scope Of Change", + "## Verification Commands And Results", + "## Compatibility And Risk", + "## Rollback Plan", + ): + assert heading in template diff --git a/tests/test_main_schedule_mode.py b/tests/test_main_schedule_mode.py index 419aa0a..30a074f 100644 --- a/tests/test_main_schedule_mode.py +++ b/tests/test_main_schedule_mode.py @@ -267,6 +267,9 @@ def fake_get_config(): def test_schedule_time_provider_propagates_config_read_failures(self) -> None: with patch( + "main._INITIAL_PROCESS_ENV", + {}, + ), patch( "src.core.config_manager.ConfigManager.read_config_map", side_effect=RuntimeError("boom"), ): diff --git a/tests/test_market_analyzer_generate_text.py b/tests/test_market_analyzer_generate_text.py index 50d7825..a91f377 100644 --- a/tests/test_market_analyzer_generate_text.py +++ b/tests/test_market_analyzer_generate_text.py @@ -403,7 +403,7 @@ def test_no_private_attribute_access_in_market_analyzer_source(self): import ast import pathlib - src = pathlib.Path("src/market_analyzer.py").read_text() + src = pathlib.Path("src/market_analyzer.py").read_text(encoding="utf-8") tree = ast.parse(src) forbidden = { "_model", "_router", "_use_openai", "_use_anthropic", # historical diff --git a/tests/test_search_service_optional_dependencies.py b/tests/test_search_service_optional_dependencies.py new file mode 100644 index 0000000..b93029a --- /dev/null +++ b/tests/test_search_service_optional_dependencies.py @@ -0,0 +1,35 @@ +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_search_service_imports_when_newspaper_is_missing(): + code = r""" +import builtins + +real_import = builtins.__import__ + +def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "newspaper" or name.startswith("newspaper."): + raise ModuleNotFoundError("No module named 'newspaper'") + return real_import(name, globals, locals, fromlist, level) + +builtins.__import__ = guarded_import + +from src.search_service import fetch_url_content + +assert fetch_url_content("https://example.invalid") == "" +""" + + result = subprocess.run( + [sys.executable, "-c", code], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode == 0, result.stderr or result.stdout diff --git a/tests/test_storage.py b/tests/test_storage.py index 02d1141..4022162 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -209,8 +209,8 @@ def worker() -> None: self.assertEqual(total, 1) finally: - temp_dir.cleanup() DatabaseManager.reset_instance() + temp_dir.cleanup() if __name__ == '__main__': unittest.main()