改为自动周期采集并优化首页导航
This commit is contained in:
+1
-2
@@ -13,8 +13,7 @@ SIGNALSCOUT_GITHUB_RECENT_DAYS=30
|
|||||||
SIGNALSCOUT_GITHUB_MIN_STARS=20
|
SIGNALSCOUT_GITHUB_MIN_STARS=20
|
||||||
SIGNALSCOUT_GITHUB_MAX_PROJECTS=5
|
SIGNALSCOUT_GITHUB_MAX_PROJECTS=5
|
||||||
SIGNALSCOUT_AGENT_TIMEZONE=Asia/Shanghai
|
SIGNALSCOUT_AGENT_TIMEZONE=Asia/Shanghai
|
||||||
SIGNALSCOUT_AGENT_CRON_HOUR=8
|
SIGNALSCOUT_AGENT_INTERVAL_MINUTES=30
|
||||||
SIGNALSCOUT_AGENT_CRON_MINUTE=30
|
|
||||||
SIGNALSCOUT_REPORT_DIR=reports
|
SIGNALSCOUT_REPORT_DIR=reports
|
||||||
SIGNALSCOUT_LOG_FILE=logs/app.log
|
SIGNALSCOUT_LOG_FILE=logs/app.log
|
||||||
SIGNALSCOUT_LOG_TAIL_LINES=200
|
SIGNALSCOUT_LOG_TAIL_LINES=200
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# SignalScout
|
# 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_RECENT_DAYS=30
|
||||||
SIGNALSCOUT_GITHUB_MIN_STARS=20
|
SIGNALSCOUT_GITHUB_MIN_STARS=20
|
||||||
SIGNALSCOUT_GITHUB_MAX_PROJECTS=5
|
SIGNALSCOUT_GITHUB_MAX_PROJECTS=5
|
||||||
|
SIGNALSCOUT_AGENT_INTERVAL_MINUTES=30
|
||||||
```
|
```
|
||||||
|
|
||||||
## 本机运行
|
## 本机运行
|
||||||
@@ -64,8 +65,8 @@ http://127.0.0.1:8010/docs
|
|||||||
## API
|
## API
|
||||||
|
|
||||||
- `GET /health`:服务健康检查
|
- `GET /health`:服务健康检查
|
||||||
- `POST /agent/runs/daily`:手动触发每日 Agent
|
- `POST /agent/runs/daily`:运维触发一次 Agent
|
||||||
- `POST /agent/runs/daily/stream`:以 SSE 方式触发每日 Agent
|
- `POST /agent/runs/daily/stream`:运维以 SSE 方式触发一次 Agent
|
||||||
- `GET /agent/runs`:查看最近运行记录
|
- `GET /agent/runs`:查看最近运行记录
|
||||||
- `GET /signals`:查看结构化情报
|
- `GET /signals`:查看结构化情报
|
||||||
- `GET /reports/latest`:查看最新日报
|
- `GET /reports/latest`:查看最新日报
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -16,15 +18,17 @@ def run_scheduled_job(settings: Settings) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def build_scheduler(settings: Settings) -> BackgroundScheduler:
|
def build_scheduler(settings: Settings) -> BackgroundScheduler:
|
||||||
# APScheduler负责每日后台运行,服务启动后自动挂载任务。
|
# APScheduler负责自动循环运行,启动后先跑一次,再按固定间隔持续采集。
|
||||||
scheduler = BackgroundScheduler(timezone=settings.agent_timezone)
|
scheduler = BackgroundScheduler(timezone=settings.agent_timezone)
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
run_scheduled_job,
|
run_scheduled_job,
|
||||||
trigger="cron",
|
trigger="interval",
|
||||||
hour=settings.agent_cron_hour,
|
minutes=settings.agent_interval_minutes,
|
||||||
minute=settings.agent_cron_minute,
|
|
||||||
args=[settings],
|
args=[settings],
|
||||||
id="daily-intelligence",
|
id="continuous-intelligence",
|
||||||
|
next_run_time=datetime.now(scheduler.timezone),
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
)
|
)
|
||||||
return scheduler
|
return scheduler
|
||||||
|
|||||||
+1
-2
@@ -24,8 +24,7 @@ class Settings(BaseSettings):
|
|||||||
github_min_stars: int = 20
|
github_min_stars: int = 20
|
||||||
github_max_projects: int = 5
|
github_max_projects: int = 5
|
||||||
agent_timezone: str = "Asia/Shanghai"
|
agent_timezone: str = "Asia/Shanghai"
|
||||||
agent_cron_hour: int = 8
|
agent_interval_minutes: int = 30
|
||||||
agent_cron_minute: int = 30
|
|
||||||
report_dir: Path = Path("reports")
|
report_dir: Path = Path("reports")
|
||||||
log_file: Path = Path("logs/app.log")
|
log_file: Path = Path("logs/app.log")
|
||||||
log_tail_lines: int = 200
|
log_tail_lines: int = 200
|
||||||
|
|||||||
+5
-1
@@ -17,8 +17,12 @@ from app.core.log_setup import configure_logging # noqa: E402
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
# The scheduler lives with the web process for the demo deployment profile.
|
# 测试环境不启动后台采集,避免接口测试触发真实外部请求。
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
if settings.env == "test":
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
# 调度器随Web进程常驻,负责自动周期性整理情报。
|
||||||
scheduler = build_scheduler(settings)
|
scheduler = build_scheduler(settings)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
app.state.scheduler = scheduler
|
app.state.scheduler = scheduler
|
||||||
|
|||||||
+150
-113
@@ -15,7 +15,6 @@ HOME_PAGE_HTML = """
|
|||||||
--panel: oklch(99% 0.006 190);
|
--panel: oklch(99% 0.006 190);
|
||||||
--panel-2: oklch(94.5% 0.018 176);
|
--panel-2: oklch(94.5% 0.018 176);
|
||||||
--accent: oklch(52% 0.12 174);
|
--accent: oklch(52% 0.12 174);
|
||||||
--accent-ink: oklch(19% 0.035 190);
|
|
||||||
--warn: oklch(67% 0.13 70);
|
--warn: oklch(67% 0.13 70);
|
||||||
--bad: oklch(58% 0.16 26);
|
--bad: oklch(58% 0.16 26);
|
||||||
--good: oklch(53% 0.12 150);
|
--good: oklch(53% 0.12 150);
|
||||||
@@ -79,7 +78,8 @@ HOME_PAGE_HTML = """
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand span,
|
.brand span,
|
||||||
.nav-note {
|
.nav-note,
|
||||||
|
.auto-card span {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
@@ -91,17 +91,37 @@ HOME_PAGE_HTML = """
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a {
|
.nav button {
|
||||||
text-decoration: none;
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a.active {
|
.nav button.active {
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
color: white;
|
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 {
|
.content {
|
||||||
@@ -134,36 +154,44 @@ HOME_PAGE_HTML = """
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.pulse {
|
||||||
display: flex;
|
min-width: 184px;
|
||||||
gap: 10px;
|
border: 1px solid var(--line);
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border: 1px solid var(--ink);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--accent);
|
padding: 12px 14px;
|
||||||
color: white;
|
background: var(--panel);
|
||||||
min-height: 42px;
|
color: var(--muted);
|
||||||
padding: 0 16px;
|
line-height: 1.55;
|
||||||
font-weight: 700;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 4px 4px 0 var(--ink);
|
|
||||||
transition: transform 160ms ease, box-shadow 160ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover { transform: translate(-1px, -1px); box-shadow: 5px 5px 0 var(--ink); }
|
.pulse strong {
|
||||||
button:disabled { opacity: 0.58; cursor: wait; transform: none; box-shadow: 2px 2px 0 var(--ink); }
|
display: block;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
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;
|
gap: 18px;
|
||||||
align-items: start;
|
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 {
|
.panel {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -235,36 +263,28 @@ HOME_PAGE_HTML = """
|
|||||||
.pill {
|
.pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 24px;
|
min-height: 24px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0 9px;
|
padding: 2px 9px;
|
||||||
background: oklch(98% 0.01 180);
|
background: oklch(98% 0.01 180);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.importance {
|
.importance {
|
||||||
color: var(--accent-ink);
|
color: oklch(19% 0.035 190);
|
||||||
border-color: oklch(72% 0.08 174);
|
border-color: oklch(72% 0.08 174);
|
||||||
background: oklch(91% 0.045 174);
|
background: oklch(91% 0.045 174);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report {
|
.report {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
min-height: 320px;
|
min-height: 520px;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report h1,
|
|
||||||
.report h2,
|
|
||||||
.report h3 {
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 0 0 10px;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.runs { display: grid; }
|
.runs { display: grid; }
|
||||||
|
|
||||||
.run-row {
|
.run-row {
|
||||||
@@ -291,28 +311,6 @@ HOME_PAGE_HTML = """
|
|||||||
line-height: 1.7;
|
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) {
|
@media (max-width: 980px) {
|
||||||
.shell { grid-template-columns: 1fr; }
|
.shell { grid-template-columns: 1fr; }
|
||||||
.rail {
|
.rail {
|
||||||
@@ -322,9 +320,9 @@ HOME_PAGE_HTML = """
|
|||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.nav { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.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; }
|
.topbar { display: grid; }
|
||||||
.actions { justify-content: flex-start; }
|
.pulse { min-width: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
@@ -332,7 +330,6 @@ HOME_PAGE_HTML = """
|
|||||||
.rail { padding: 18px; }
|
.rail { padding: 18px; }
|
||||||
.nav { grid-template-columns: 1fr; }
|
.nav { grid-template-columns: 1fr; }
|
||||||
h1 { font-size: 30px; }
|
h1 { font-size: 30px; }
|
||||||
button { width: 100%; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -345,26 +342,31 @@ HOME_PAGE_HTML = """
|
|||||||
<span>AI 情报采集与研判工作台</span>
|
<span>AI 情报采集与研判工作台</span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav" aria-label="主导航">
|
<nav class="nav" aria-label="主导航">
|
||||||
<a class="active" href="#signals">情报</a>
|
<button class="active" type="button" data-view-target="signals">情报</button>
|
||||||
<a href="#report">日报</a>
|
<button type="button" data-view-target="report">日报</button>
|
||||||
<a href="#runs">运行</a>
|
<button type="button" data-view-target="runs">运行</button>
|
||||||
</nav>
|
</nav>
|
||||||
<p class="nav-note">每天自动整理 AI 新闻、模型发布、Agent 工具和 GitHub 热门项目。</p>
|
<div class="auto-card">
|
||||||
|
<strong>自动整理</strong>
|
||||||
|
<span>启动后自动运行,之后约每 30 分钟整理一次。</span>
|
||||||
|
</div>
|
||||||
|
<p class="nav-note">聚合 AI 新闻、模型发布、Agent 工具和 GitHub 热门项目。</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<section class="topbar">
|
<section class="topbar">
|
||||||
<div>
|
<div>
|
||||||
<h1>今日 AI 情报</h1>
|
<h1>今日 AI 情报</h1>
|
||||||
<p class="subtitle">聚合最新新闻和开源热度,由 Agent 统一筛选、去重和生成日报。</p>
|
<p class="subtitle">Agent 自动筛选、去重和生成日报;页面会持续更新最新结果。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="pulse">
|
||||||
<button id="runButton" type="button">采集今日情报</button>
|
<strong id="lastRunText">等待运行</strong>
|
||||||
|
<span id="lastRunDetail">正在读取运行记录。</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="layout">
|
<section class="view active" data-view="signals">
|
||||||
<div class="panel" id="signals">
|
<div class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>情报信号</h2>
|
<h2>情报信号</h2>
|
||||||
<span class="count" id="signalCount">0 条</span>
|
<span class="count" id="signalCount">0 条</span>
|
||||||
@@ -375,39 +377,65 @@ HOME_PAGE_HTML = """
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<div class="panel" id="report">
|
<div class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>最新日报</h2>
|
<h2>最新日报</h2>
|
||||||
<span class="count" id="reportTime">待生成</span>
|
<span class="count" id="reportTime">待生成</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="report" id="reportBody">正在读取日报。</div>
|
<div class="report" id="reportPreview">正在读取日报。</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel" id="runs">
|
<div class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>最近运行</h2>
|
<h2>最近运行</h2>
|
||||||
<span class="count" id="runCount">0 次</span>
|
<span class="count" id="runCount">0 次</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="runs" id="runList">
|
<div class="runs" id="runPreview">
|
||||||
<div class="empty">正在读取运行记录。</div>
|
<div class="empty">正在读取运行记录。</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="view" data-view="report">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>最新日报</h2>
|
||||||
|
<span class="count" id="reportTimeFull">待生成</span>
|
||||||
|
</div>
|
||||||
|
<div class="report" id="reportBody">正在读取日报。</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view" data-view="runs">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>最近运行</h2>
|
||||||
|
<span class="count" id="runCountFull">0 次</span>
|
||||||
|
</div>
|
||||||
|
<div class="runs" id="runList">
|
||||||
|
<div class="empty">正在读取运行记录。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const navButtons = Array.from(document.querySelectorAll("[data-view-target]"));
|
||||||
|
const views = Array.from(document.querySelectorAll("[data-view]"));
|
||||||
const signalList = document.querySelector("#signalList");
|
const signalList = document.querySelector("#signalList");
|
||||||
const signalCount = document.querySelector("#signalCount");
|
const signalCount = document.querySelector("#signalCount");
|
||||||
|
const reportPreview = document.querySelector("#reportPreview");
|
||||||
const reportBody = document.querySelector("#reportBody");
|
const reportBody = document.querySelector("#reportBody");
|
||||||
const reportTime = document.querySelector("#reportTime");
|
const reportTime = document.querySelector("#reportTime");
|
||||||
|
const reportTimeFull = document.querySelector("#reportTimeFull");
|
||||||
|
const runPreview = document.querySelector("#runPreview");
|
||||||
const runList = document.querySelector("#runList");
|
const runList = document.querySelector("#runList");
|
||||||
const runCount = document.querySelector("#runCount");
|
const runCount = document.querySelector("#runCount");
|
||||||
const runButton = document.querySelector("#runButton");
|
const runCountFull = document.querySelector("#runCountFull");
|
||||||
const toast = document.querySelector("#toast");
|
const lastRunText = document.querySelector("#lastRunText");
|
||||||
|
const lastRunDetail = document.querySelector("#lastRunDetail");
|
||||||
|
|
||||||
const stateText = {
|
const stateText = {
|
||||||
completed: "完成",
|
completed: "完成",
|
||||||
@@ -436,16 +464,16 @@ HOME_PAGE_HTML = """
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToast(message) {
|
function activateView(name, updateHash = true) {
|
||||||
toast.textContent = message;
|
navButtons.forEach((button) => button.classList.toggle("active", button.dataset.viewTarget === name));
|
||||||
toast.classList.add("show");
|
views.forEach((view) => view.classList.toggle("active", view.dataset.view === name));
|
||||||
window.setTimeout(() => toast.classList.remove("show"), 3200);
|
if (updateHash) history.replaceState(null, "", `#${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSignals(signals) {
|
function renderSignals(signals) {
|
||||||
signalCount.textContent = `${signals.length} 条`;
|
signalCount.textContent = `${signals.length} 条`;
|
||||||
if (!signals.length) {
|
if (!signals.length) {
|
||||||
signalList.innerHTML = '<div class="empty">还没有情报信号,可以先采集今日情报。</div>';
|
signalList.innerHTML = '<div class="empty">Agent 正在按固定节奏自动整理,完成后这里会出现情报信号。</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
signalList.innerHTML = signals.map((item) => `
|
signalList.innerHTML = signals.map((item) => `
|
||||||
@@ -468,20 +496,21 @@ HOME_PAGE_HTML = """
|
|||||||
function renderReport(report) {
|
function renderReport(report) {
|
||||||
if (!report) {
|
if (!report) {
|
||||||
reportTime.textContent = "待生成";
|
reportTime.textContent = "待生成";
|
||||||
reportBody.textContent = "还没有日报,可以先采集今日情报。";
|
reportTimeFull.textContent = "待生成";
|
||||||
|
reportPreview.textContent = "Agent 正在自动整理,日报生成后会显示在这里。";
|
||||||
|
reportBody.textContent = "Agent 正在自动整理,日报生成后会显示在这里。";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reportTime.textContent = formatDate(report.created_at);
|
const timeText = formatDate(report.created_at);
|
||||||
reportBody.textContent = report.content_md || "日报内容为空。";
|
const content = report.content_md || "日报内容为空。";
|
||||||
|
reportTime.textContent = timeText;
|
||||||
|
reportTimeFull.textContent = timeText;
|
||||||
|
reportPreview.textContent = content;
|
||||||
|
reportBody.textContent = content;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRuns(runs) {
|
function runRows(runs) {
|
||||||
runCount.textContent = `${runs.length} 次`;
|
return runs.map((run) => `
|
||||||
if (!runs.length) {
|
|
||||||
runList.innerHTML = '<div class="empty">还没有运行记录。</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
runList.innerHTML = runs.map((run) => `
|
|
||||||
<div class="run-row">
|
<div class="run-row">
|
||||||
<span>#${escapeHtml(run.id)} · ${formatDate(run.started_at)}</span>
|
<span>#${escapeHtml(run.id)} · ${formatDate(run.started_at)}</span>
|
||||||
<span class="state ${escapeHtml(run.status)}">${escapeHtml(stateText[run.status] || run.status)}</span>
|
<span class="state ${escapeHtml(run.status)}">${escapeHtml(stateText[run.status] || run.status)}</span>
|
||||||
@@ -489,6 +518,23 @@ HOME_PAGE_HTML = """
|
|||||||
`).join("");
|
`).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRuns(runs) {
|
||||||
|
runCount.textContent = `${runs.length} 次`;
|
||||||
|
runCountFull.textContent = `${runs.length} 次`;
|
||||||
|
if (!runs.length) {
|
||||||
|
runPreview.innerHTML = '<div class="empty">Agent 会在启动后自动开始整理。</div>';
|
||||||
|
runList.innerHTML = '<div class="empty">Agent 会在启动后自动开始整理。</div>';
|
||||||
|
lastRunText.textContent = "等待运行";
|
||||||
|
lastRunDetail.textContent = "启动后自动运行,之后约每 30 分钟整理一次。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runPreview.innerHTML = runRows(runs.slice(0, 4));
|
||||||
|
runList.innerHTML = runRows(runs);
|
||||||
|
const latest = runs[0];
|
||||||
|
lastRunText.textContent = stateText[latest.status] || latest.status;
|
||||||
|
lastRunDetail.textContent = `最近一次:${formatDate(latest.started_at)}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
const response = await fetch("/dashboard", { headers: { Accept: "application/json" } });
|
const response = await fetch("/dashboard", { headers: { Accept: "application/json" } });
|
||||||
if (!response.ok) throw new Error("读取情报失败");
|
if (!response.ok) throw new Error("读取情报失败");
|
||||||
@@ -498,32 +544,23 @@ HOME_PAGE_HTML = """
|
|||||||
renderRuns(data.runs || []);
|
renderRuns(data.runs || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAgent() {
|
navButtons.forEach((button) => {
|
||||||
runButton.disabled = true;
|
button.addEventListener("click", () => activateView(button.dataset.viewTarget));
|
||||||
runButton.textContent = "采集中";
|
});
|
||||||
showToast("Agent 已开始采集今日情报。");
|
|
||||||
try {
|
const initialView = window.location.hash.replace("#", "");
|
||||||
const response = await fetch("/agent/runs/daily", { method: "POST" });
|
if (["signals", "report", "runs"].includes(initialView)) {
|
||||||
if (!response.ok) {
|
activateView(initialView, false);
|
||||||
const body = await response.text();
|
|
||||||
throw new Error(body || "采集失败");
|
|
||||||
}
|
|
||||||
showToast("今日情报采集完成。");
|
|
||||||
await loadDashboard();
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error.message || "采集失败,请查看日志。");
|
|
||||||
} finally {
|
|
||||||
runButton.disabled = false;
|
|
||||||
runButton.textContent = "采集今日情报";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
runButton.addEventListener("click", runAgent);
|
|
||||||
loadDashboard().catch(() => {
|
loadDashboard().catch(() => {
|
||||||
signalList.innerHTML = '<div class="empty">情报读取失败,请稍后再试。</div>';
|
signalList.innerHTML = '<div class="empty">情报读取失败,请稍后再试。</div>';
|
||||||
|
reportPreview.textContent = "日报读取失败。";
|
||||||
reportBody.textContent = "日报读取失败。";
|
reportBody.textContent = "日报读取失败。";
|
||||||
|
runPreview.innerHTML = '<div class="empty">运行记录读取失败。</div>';
|
||||||
runList.innerHTML = '<div class="empty">运行记录读取失败。</div>';
|
runList.innerHTML = '<div class="empty">运行记录读取失败。</div>';
|
||||||
});
|
});
|
||||||
|
window.setInterval(loadDashboard, 60000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
|
|
||||||
|
|
||||||
def test_health_endpoint_returns_app_identity() -> None:
|
def test_health_endpoint_returns_app_identity(monkeypatch) -> None:
|
||||||
# 健康检查只暴露服务身份,不触发Agent运行或数据库写入。
|
# 健康检查只暴露服务身份,不触发Agent运行或数据库写入。
|
||||||
|
monkeypatch.setenv("SIGNALSCOUT_ENV", "test")
|
||||||
|
get_settings.cache_clear()
|
||||||
with TestClient(create_app()) as client:
|
with TestClient(create_app()) as client:
|
||||||
response = client.get("/health")
|
response = client.get("/health")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["status"] == "ok"
|
assert response.json()["status"] == "ok"
|
||||||
assert response.json()["app"] == "SignalScout"
|
assert response.json()["app"] == "SignalScout"
|
||||||
|
|
||||||
|
|
||||||
def test_home_page_returns_dashboard_shell() -> None:
|
def test_home_page_returns_dashboard_shell(monkeypatch) -> None:
|
||||||
# 根路径应该展示业务工作台,而不是返回接口404。
|
# 根路径应该展示业务工作台,而不是返回接口404。
|
||||||
|
monkeypatch.setenv("SIGNALSCOUT_ENV", "test")
|
||||||
|
get_settings.cache_clear()
|
||||||
with TestClient(create_app()) as client:
|
with TestClient(create_app()) as client:
|
||||||
response = client.get("/")
|
response = client.get("/")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "SignalScout" in response.text
|
assert "SignalScout" in response.text
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user