From da5e0073248f5ecaba6e1ce1943521b89768117f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 8 Jul 2026 22:38:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E4=B8=BA=E8=87=AA=E5=8A=A8=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E9=87=87=E9=9B=86=E5=B9=B6=E4=BC=98=E5=8C=96=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 +- README.md | 7 +- app/agent/scheduler.py | 14 ++- app/core/config.py | 3 +- app/main.py | 6 +- app/ui/home.py | 263 ++++++++++++++++++++++----------------- tests/test_api_health.py | 11 +- tests/test_scheduler.py | 12 ++ 8 files changed, 191 insertions(+), 128 deletions(-) create mode 100644 tests/test_scheduler.py diff --git a/.env.example b/.env.example index 4143a41..0d93ac5 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,7 @@ SIGNALSCOUT_GITHUB_RECENT_DAYS=30 SIGNALSCOUT_GITHUB_MIN_STARS=20 SIGNALSCOUT_GITHUB_MAX_PROJECTS=5 SIGNALSCOUT_AGENT_TIMEZONE=Asia/Shanghai -SIGNALSCOUT_AGENT_CRON_HOUR=8 -SIGNALSCOUT_AGENT_CRON_MINUTE=30 +SIGNALSCOUT_AGENT_INTERVAL_MINUTES=30 SIGNALSCOUT_REPORT_DIR=reports SIGNALSCOUT_LOG_FILE=logs/app.log SIGNALSCOUT_LOG_TAIL_LINES=200 diff --git a/README.md b/README.md index 955b007..3c945d4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SignalScout -SignalScout 是一个定时运行的 AI 情报 Agent。它先用动态新闻搜索和 GitHub 官方搜索抓取候选证据,再使用 Grok 通过中转 API 生成最新 AI 新闻、模型发布、Agent 工具、融资动态和 GitHub 热门项目日报,并把结构化情报保存到 MySQL。 +SignalScout 是一个自动运行的 AI 情报 Agent。它先用动态新闻搜索和 GitHub 官方搜索抓取候选证据,再使用 Grok 通过中转 API 生成最新 AI 新闻、模型发布、Agent 工具、融资动态和 GitHub 热门项目日报,并把结构化情报保存到 MySQL。 ## 主流程 @@ -35,6 +35,7 @@ SIGNALSCOUT_NEWS_SEARCH_MAX_RECORDS=20 SIGNALSCOUT_GITHUB_RECENT_DAYS=30 SIGNALSCOUT_GITHUB_MIN_STARS=20 SIGNALSCOUT_GITHUB_MAX_PROJECTS=5 +SIGNALSCOUT_AGENT_INTERVAL_MINUTES=30 ``` ## 本机运行 @@ -64,8 +65,8 @@ http://127.0.0.1:8010/docs ## API - `GET /health`:服务健康检查 -- `POST /agent/runs/daily`:手动触发每日 Agent -- `POST /agent/runs/daily/stream`:以 SSE 方式触发每日 Agent +- `POST /agent/runs/daily`:运维触发一次 Agent +- `POST /agent/runs/daily/stream`:运维以 SSE 方式触发一次 Agent - `GET /agent/runs`:查看最近运行记录 - `GET /signals`:查看结构化情报 - `GET /reports/latest`:查看最新日报 diff --git a/app/agent/scheduler.py b/app/agent/scheduler.py index 6115502..7f5f50b 100644 --- a/app/agent/scheduler.py +++ b/app/agent/scheduler.py @@ -1,3 +1,5 @@ +from datetime import datetime + from apscheduler.schedulers.background import BackgroundScheduler from sqlalchemy.orm import Session @@ -16,15 +18,17 @@ def run_scheduled_job(settings: Settings) -> None: def build_scheduler(settings: Settings) -> BackgroundScheduler: - # APScheduler负责每日后台运行,服务启动后自动挂载任务。 + # APScheduler负责自动循环运行,启动后先跑一次,再按固定间隔持续采集。 scheduler = BackgroundScheduler(timezone=settings.agent_timezone) scheduler.add_job( run_scheduled_job, - trigger="cron", - hour=settings.agent_cron_hour, - minute=settings.agent_cron_minute, + trigger="interval", + minutes=settings.agent_interval_minutes, args=[settings], - id="daily-intelligence", + id="continuous-intelligence", + next_run_time=datetime.now(scheduler.timezone), replace_existing=True, + max_instances=1, + coalesce=True, ) return scheduler diff --git a/app/core/config.py b/app/core/config.py index 5bad03b..fe7a9de 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -24,8 +24,7 @@ class Settings(BaseSettings): github_min_stars: int = 20 github_max_projects: int = 5 agent_timezone: str = "Asia/Shanghai" - agent_cron_hour: int = 8 - agent_cron_minute: int = 30 + agent_interval_minutes: int = 30 report_dir: Path = Path("reports") log_file: Path = Path("logs/app.log") log_tail_lines: int = 200 diff --git a/app/main.py b/app/main.py index 5b62835..eba59ab 100644 --- a/app/main.py +++ b/app/main.py @@ -17,8 +17,12 @@ from app.core.log_setup import configure_logging # noqa: E402 @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: - # The scheduler lives with the web process for the demo deployment profile. + # 测试环境不启动后台采集,避免接口测试触发真实外部请求。 settings = get_settings() + if settings.env == "test": + yield + return + # 调度器随Web进程常驻,负责自动周期性整理情报。 scheduler = build_scheduler(settings) scheduler.start() app.state.scheduler = scheduler diff --git a/app/ui/home.py b/app/ui/home.py index 685346a..ec02dcf 100644 --- a/app/ui/home.py +++ b/app/ui/home.py @@ -15,7 +15,6 @@ HOME_PAGE_HTML = """ --panel: oklch(99% 0.006 190); --panel-2: oklch(94.5% 0.018 176); --accent: oklch(52% 0.12 174); - --accent-ink: oklch(19% 0.035 190); --warn: oklch(67% 0.13 70); --bad: oklch(58% 0.16 26); --good: oklch(53% 0.12 150); @@ -79,7 +78,8 @@ HOME_PAGE_HTML = """ } .brand span, - .nav-note { + .nav-note, + .auto-card span { color: var(--muted); font-size: 12px; line-height: 1.55; @@ -91,17 +91,37 @@ HOME_PAGE_HTML = """ margin-top: 24px; } - .nav a { - text-decoration: none; + .nav button { + border: 0; + width: 100%; + text-align: left; + background: transparent; padding: 10px 12px; border-radius: 8px; color: var(--muted); + font: inherit; font-size: 14px; + cursor: pointer; } - .nav a.active { + .nav button.active { background: var(--ink); color: white; + font-weight: 700; + } + + .auto-card { + display: grid; + gap: 6px; + margin-top: 24px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: oklch(99% 0.006 180 / 0.78); + } + + .auto-card strong { + font-size: 13px; } .content { @@ -134,36 +154,44 @@ HOME_PAGE_HTML = """ line-height: 1.7; } - .actions { - display: flex; - gap: 10px; - flex-wrap: wrap; - justify-content: flex-end; - } - - button { - border: 1px solid var(--ink); + .pulse { + min-width: 184px; + border: 1px solid var(--line); border-radius: 8px; - background: var(--accent); - color: white; - min-height: 42px; - padding: 0 16px; - font-weight: 700; - cursor: pointer; - box-shadow: 4px 4px 0 var(--ink); - transition: transform 160ms ease, box-shadow 160ms ease; + padding: 12px 14px; + background: var(--panel); + color: var(--muted); + line-height: 1.55; + font-size: 13px; } - button:hover { transform: translate(-1px, -1px); box-shadow: 5px 5px 0 var(--ink); } - button:disabled { opacity: 0.58; cursor: wait; transform: none; box-shadow: 2px 2px 0 var(--ink); } + .pulse strong { + display: block; + color: var(--ink); + font-size: 18px; + line-height: 1.2; + } .layout { display: grid; - grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr); + grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr); gap: 18px; align-items: start; } + .view { + display: none; + gap: 18px; + } + + .view.active { + display: grid; + } + + .view[data-view="signals"].active { + grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr); + } + .panel { background: var(--panel); border: 1px solid var(--line); @@ -235,36 +263,28 @@ HOME_PAGE_HTML = """ .pill { display: inline-flex; align-items: center; - height: 24px; + min-height: 24px; border: 1px solid var(--line); border-radius: 999px; - padding: 0 9px; + padding: 2px 9px; background: oklch(98% 0.01 180); color: var(--muted); max-width: 100%; } .importance { - color: var(--accent-ink); + color: oklch(19% 0.035 190); border-color: oklch(72% 0.08 174); background: oklch(91% 0.045 174); } .report { padding: 18px; - min-height: 320px; + min-height: 520px; line-height: 1.75; white-space: pre-wrap; } - .report h1, - .report h2, - .report h3 { - font-size: 16px; - margin: 0 0 10px; - line-height: 1.35; - } - .runs { display: grid; } .run-row { @@ -291,28 +311,6 @@ HOME_PAGE_HTML = """ line-height: 1.7; } - .toast { - position: fixed; - right: 22px; - bottom: 22px; - max-width: 360px; - background: var(--ink); - color: white; - padding: 12px 14px; - border-radius: 8px; - opacity: 0; - transform: translateY(12px); - pointer-events: none; - transition: opacity 160ms ease, transform 160ms ease; - line-height: 1.55; - z-index: 20; - } - - .toast.show { - opacity: 1; - transform: translateY(0); - } - @media (max-width: 980px) { .shell { grid-template-columns: 1fr; } .rail { @@ -322,9 +320,9 @@ HOME_PAGE_HTML = """ border-bottom: 1px solid var(--line); } .nav { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .layout { grid-template-columns: 1fr; } + .view[data-view="signals"].active { grid-template-columns: 1fr; } .topbar { display: grid; } - .actions { justify-content: flex-start; } + .pulse { min-width: 0; } } @media (max-width: 560px) { @@ -332,7 +330,6 @@ HOME_PAGE_HTML = """ .rail { padding: 18px; } .nav { grid-template-columns: 1fr; } h1 { font-size: 30px; } - button { width: 100%; } } @@ -345,26 +342,31 @@ HOME_PAGE_HTML = """ AI 情报采集与研判工作台 - +
+ 自动整理 + 启动后自动运行,之后约每 30 分钟整理一次。 +
+

今日 AI 情报

-

聚合最新新闻和开源热度,由 Agent 统一筛选、去重和生成日报。

+

Agent 自动筛选、去重和生成日报;页面会持续更新最新结果。

-
- +
+ 等待运行 + 正在读取运行记录。
-
-
+
+

情报信号

0 条 @@ -375,39 +377,65 @@ HOME_PAGE_HTML = """
-
+

最新日报

待生成
-
正在读取日报。
+
正在读取日报。
-
+

最近运行

0 次
-
+
正在读取运行记录。
+ +
+
+
+

最新日报

+ 待生成 +
+
正在读取日报。
+
+
+ +
+
+
+

最近运行

+ 0 次 +
+
+
正在读取运行记录。
+
+
+
-
- diff --git a/tests/test_api_health.py b/tests/test_api_health.py index 7b5873e..2a72407 100644 --- a/tests/test_api_health.py +++ b/tests/test_api_health.py @@ -1,22 +1,29 @@ from fastapi.testclient import TestClient +from app.core.config import get_settings from app.main import create_app -def test_health_endpoint_returns_app_identity() -> None: +def test_health_endpoint_returns_app_identity(monkeypatch) -> None: # 健康检查只暴露服务身份,不触发Agent运行或数据库写入。 + monkeypatch.setenv("SIGNALSCOUT_ENV", "test") + get_settings.cache_clear() with TestClient(create_app()) as client: response = client.get("/health") + get_settings.cache_clear() assert response.status_code == 200 assert response.json()["status"] == "ok" assert response.json()["app"] == "SignalScout" -def test_home_page_returns_dashboard_shell() -> None: +def test_home_page_returns_dashboard_shell(monkeypatch) -> None: # 根路径应该展示业务工作台,而不是返回接口404。 + monkeypatch.setenv("SIGNALSCOUT_ENV", "test") + get_settings.cache_clear() with TestClient(create_app()) as client: response = client.get("/") + get_settings.cache_clear() assert response.status_code == 200 assert "SignalScout" in response.text diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..b412e9a --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,12 @@ +from app.agent.scheduler import build_scheduler +from app.core.config import Settings + + +def test_scheduler_runs_on_interval() -> None: + # Agent默认按固定间隔自动运行,避免产品依赖用户手动触发。 + scheduler = build_scheduler(Settings(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