diff --git a/.env.example b/.env.example index 8c99817..5d63a69 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ SIGNALSCOUT_ENV=dev SIGNALSCOUT_DATABASE_URL=mysql+pymysql://signalscout:signalscout@127.0.0.1:3306/signalscout?charset=utf8mb4 SIGNALSCOUT_LLM_BASE_URL=https://api.zayuapi.com/v1 SIGNALSCOUT_LLM_API_KEY= -SIGNALSCOUT_LLM_MODEL=grok-4.3 +SIGNALSCOUT_LLM_MODEL=grok-4.5 SIGNALSCOUT_LLM_TIMEOUT_SECONDS=180 SIGNALSCOUT_LLM_MAX_TOKENS=8000 SIGNALSCOUT_NEWS_RECENT_DAYS=3 @@ -13,6 +13,7 @@ SIGNALSCOUT_GITHUB_RECENT_DAYS=30 SIGNALSCOUT_GITHUB_MIN_STARS=20 SIGNALSCOUT_GITHUB_MAX_PROJECTS=40 SIGNALSCOUT_AGENT_TIMEZONE=Asia/Shanghai +SIGNALSCOUT_AGENT_ENABLED=false SIGNALSCOUT_AGENT_INTERVAL_MINUTES=30 SIGNALSCOUT_REPORT_DIR=reports SIGNALSCOUT_LOG_FILE=logs/app.log diff --git a/README.md b/README.md index e8114a7..dc12998 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ cp .env.example .env SIGNALSCOUT_DATABASE_URL=mysql+pymysql://user:password@host:3306/signalscout?charset=utf8mb4 SIGNALSCOUT_LLM_BASE_URL=https://api.zayuapi.com/v1 SIGNALSCOUT_LLM_API_KEY=你的中转站 key -SIGNALSCOUT_LLM_MODEL=grok-4.3 +SIGNALSCOUT_LLM_MODEL=grok-4.5 SIGNALSCOUT_LLM_TIMEOUT_SECONDS=180 SIGNALSCOUT_LLM_MAX_TOKENS=8000 SIGNALSCOUT_NEWS_RECENT_DAYS=3 @@ -37,11 +37,14 @@ SIGNALSCOUT_NEWS_SEARCH_MAX_RECORDS=100 SIGNALSCOUT_GITHUB_RECENT_DAYS=30 SIGNALSCOUT_GITHUB_MIN_STARS=20 SIGNALSCOUT_GITHUB_MAX_PROJECTS=40 +SIGNALSCOUT_AGENT_ENABLED=false SIGNALSCOUT_AGENT_INTERVAL_MINUTES=30 ``` 单次运行的目标上限是新闻 60 条、GitHub 项目 40 条;实际入库数量会根据候选质量、链接去重和模型筛选结果浮动。 +设置 `SIGNALSCOUT_AGENT_ENABLED=true` 后,服务才会启用定时和手动情报采集。 + ## 本机运行 按项目规则,本机 Python 使用: diff --git a/app/agent/scheduler.py b/app/agent/scheduler.py index b952bd4..5286f8d 100644 --- a/app/agent/scheduler.py +++ b/app/agent/scheduler.py @@ -16,8 +16,12 @@ def run_scheduled_job(settings: Settings) -> None: def build_scheduler(settings: Settings) -> BackgroundScheduler: - # APScheduler负责按固定间隔自动运行,避免服务重启时连续触发外部采集限流。 + # 调度器始终可供手动任务复用,自动采集关闭时不注册任何周期任务。 scheduler = BackgroundScheduler(timezone=settings.agent_timezone) + if not settings.agent_enabled: + return scheduler + + # APScheduler负责按固定间隔自动运行,避免服务重启时连续触发外部采集限流。 scheduler.add_job( run_scheduled_job, trigger="interval", diff --git a/app/api/routes.py b/app/api/routes.py index 9da625e..3af5eef 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta from typing import Literal, Optional -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from sqlalchemy import select from sqlalchemy.orm import Session @@ -50,6 +50,9 @@ def trigger_daily_run( settings: Settings = Depends(get_settings), db: Session = Depends(get_db), ) -> RunTriggerRead: + # 暂停期间拒绝所有入口的采集请求,保证不会产生外部模型调用。 + if not settings.agent_enabled: + raise HTTPException(status_code=503, detail="情报采集当前已暂停") # 手动搜索投递到调度器线程,避免HTTP请求承载长时间Grok搜索任务。 running = db.scalar( select(AgentRun).where(AgentRun.status == "running").order_by(AgentRun.id.desc()).limit(1) diff --git a/app/core/config.py b/app/core/config.py index 6d0667d..3cb9f79 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -14,7 +14,7 @@ class Settings(BaseSettings): ) llm_base_url: str = "https://api.zayuapi.com/v1" llm_api_key: str = "" - llm_model: str = "grok-4.3" + llm_model: str = "grok-4.5" llm_timeout_seconds: int = 180 llm_max_tokens: int = 8000 news_recent_days: int = 3 @@ -24,6 +24,8 @@ class Settings(BaseSettings): github_min_stars: int = 20 github_max_projects: int = 40 agent_timezone: str = "Asia/Shanghai" + # 采集开关统一控制定时任务和手动触发,暂停期默认不调用外部模型。 + agent_enabled: bool = False agent_interval_minutes: int = 30 report_dir: Path = Path("reports") log_file: Path = Path("logs/app.log") diff --git a/app/ui/home.py b/app/ui/home.py index ada7655..a7e8347 100644 --- a/app/ui/home.py +++ b/app/ui/home.py @@ -162,30 +162,6 @@ HOME_PAGE_HTML = """ margin-top: 5px; } - .actions { - display: flex; - justify-content: flex-end; - margin-top: 12px; - } - - .action-button { - border: 1px solid oklch(25% 0.02 210); - border-radius: 8px; - padding: 9px 13px; - background: var(--ink); - color: white; - font: inherit; - font-size: 13px; - font-weight: 700; - cursor: pointer; - min-width: 96px; - } - - .action-button:disabled { - cursor: wait; - opacity: 0.66; - } - .layout { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr); @@ -434,9 +410,6 @@ HOME_PAGE_HTML = """
读取中 正在整理新闻与项目。 -
- -
@@ -511,7 +484,6 @@ HOME_PAGE_HTML = """ const runCountFull = document.querySelector("#runCountFull"); const todayCountText = document.querySelector("#todayCountText"); const todayCountDetail = document.querySelector("#todayCountDetail"); - const manualRunButton = document.querySelector("#manualRunButton"); const stateText = { completed: "完成", @@ -695,34 +667,9 @@ HOME_PAGE_HTML = """ renderRuns(data.runs || []); } - async function triggerManualRun() { - manualRunButton.disabled = true; - manualRunButton.textContent = "搜索中"; - try { - const response = await fetch("/agent/runs/daily", { - method: "POST", - headers: { Accept: "application/json" } - }); - if (!response.ok) throw new Error("手动搜索失败"); - await loadDashboard(); - } finally { - window.setTimeout(() => { - manualRunButton.disabled = false; - manualRunButton.textContent = "手动搜索"; - loadDashboard().catch(() => {}); - }, 3000); - } - } - navButtons.forEach((button) => { button.addEventListener("click", () => activateView(button.dataset.viewTarget)); }); - manualRunButton.addEventListener("click", () => { - triggerManualRun().catch(() => { - manualRunButton.disabled = false; - manualRunButton.textContent = "手动搜索"; - }); - }); const initialView = window.location.hash.replace("#", ""); if (["signals", "report", "runs"].includes(initialView)) { diff --git a/tests/test_api_health.py b/tests/test_api_health.py index da6323f..bb541fa 100644 --- a/tests/test_api_health.py +++ b/tests/test_api_health.py @@ -36,15 +36,16 @@ def test_home_page_returns_dashboard_shell(monkeypatch) -> None: assert response.status_code == 200 assert "SignalScout" in response.text assert "今日 AI 信号" in response.text - assert "手动搜索" in response.text + assert "手动搜索" not in response.text assert "renderMarkdownReport" in response.text assert "自动记录" not in response.text assert "采集今日情报" not in response.text -def test_manual_agent_run_endpoint_starts_background_job(monkeypatch) -> None: - # 手动搜索入口复用调度任务,方便验证每轮候选和入库数量。 +def test_manual_agent_run_endpoint_starts_background_job_when_enabled(monkeypatch) -> None: + # 启用采集后手动入口复用调度任务,方便验证每轮候选和入库数量。 monkeypatch.setenv("SIGNALSCOUT_ENV", "test") + monkeypatch.setenv("SIGNALSCOUT_AGENT_ENABLED", "true") get_settings.cache_clear() started = [] engine = create_engine( @@ -82,6 +83,7 @@ def test_manual_agent_run_endpoint_starts_background_job(monkeypatch) -> None: def test_manual_agent_run_endpoint_uses_scheduler_when_available(monkeypatch) -> None: # 生产环境手动搜索投递到常驻调度器,避免HTTP请求承载长时间联网搜索。 monkeypatch.setenv("SIGNALSCOUT_ENV", "test") + monkeypatch.setenv("SIGNALSCOUT_AGENT_ENABLED", "true") get_settings.cache_clear() engine = create_engine( "sqlite:///:memory:", @@ -121,6 +123,19 @@ def test_manual_agent_run_endpoint_uses_scheduler_when_available(monkeypatch) -> assert scheduled[0]["run_date"].tzinfo is not None +def test_manual_agent_run_endpoint_rejects_requests_when_disabled(monkeypatch) -> None: + # 采集暂停时接口拒绝请求,避免其他调用方绕过页面入口消耗模型额度。 + monkeypatch.setenv("SIGNALSCOUT_ENV", "test") + monkeypatch.setenv("SIGNALSCOUT_AGENT_ENABLED", "false") + get_settings.cache_clear() + with TestClient(create_app()) as client: + response = client.post("/agent/runs/daily") + get_settings.cache_clear() + + assert response.status_code == 503 + assert response.json()["detail"] == "情报采集当前已暂停" + + def test_dashboard_returns_news_and_github_signals_separately(monkeypatch) -> None: # 仪表盘接口直接返回分组后的业务数据,前端不再用标题或来源名自行猜测分类。 monkeypatch.setenv("SIGNALSCOUT_ENV", "test") diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index b412e9a..daca5b5 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -3,10 +3,17 @@ from app.core.config import Settings def test_scheduler_runs_on_interval() -> None: - # Agent默认按固定间隔自动运行,避免产品依赖用户手动触发。 - scheduler = build_scheduler(Settings(agent_interval_minutes=30)) + # 启用采集后按固定间隔自动运行,避免产品依赖用户手动触发。 + scheduler = build_scheduler(Settings(agent_enabled=True, agent_interval_minutes=30)) job = scheduler.get_job("continuous-intelligence") assert job is not None assert job.trigger.interval.total_seconds() == 1800 assert job.max_instances == 1 + + +def test_scheduler_skips_periodic_job_when_agent_is_disabled() -> None: + # 采集暂停时不注册任务,确保调度器没有可执行的外部模型调用。 + scheduler = build_scheduler(Settings(agent_enabled=False)) + + assert scheduler.get_job("continuous-intelligence") is None