优化情报分栏和日报展示

This commit is contained in:
Codex
2026-07-08 23:39:41 +08:00
parent 88e1cf4b7a
commit 8e15211f07
16 changed files with 371 additions and 93 deletions
+4 -4
View File
@@ -5,13 +5,13 @@ SIGNALSCOUT_LLM_BASE_URL=https://api.zayuapi.com/v1
SIGNALSCOUT_LLM_API_KEY= SIGNALSCOUT_LLM_API_KEY=
SIGNALSCOUT_LLM_MODEL=grok-4.3 SIGNALSCOUT_LLM_MODEL=grok-4.3
SIGNALSCOUT_LLM_TIMEOUT_SECONDS=90 SIGNALSCOUT_LLM_TIMEOUT_SECONDS=90
SIGNALSCOUT_LLM_MAX_TOKENS=4000 SIGNALSCOUT_LLM_MAX_TOKENS=8000
SIGNALSCOUT_NEWS_RECENT_DAYS=3 SIGNALSCOUT_NEWS_RECENT_DAYS=3
SIGNALSCOUT_NEWS_MAX_ITEMS=12 SIGNALSCOUT_NEWS_MAX_ITEMS=60
SIGNALSCOUT_NEWS_SEARCH_MAX_RECORDS=20 SIGNALSCOUT_NEWS_SEARCH_MAX_RECORDS=100
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=40
SIGNALSCOUT_AGENT_TIMEZONE=Asia/Shanghai SIGNALSCOUT_AGENT_TIMEZONE=Asia/Shanghai
SIGNALSCOUT_AGENT_INTERVAL_MINUTES=30 SIGNALSCOUT_AGENT_INTERVAL_MINUTES=30
SIGNALSCOUT_REPORT_DIR=reports SIGNALSCOUT_REPORT_DIR=reports
+7 -4
View File
@@ -29,15 +29,18 @@ SIGNALSCOUT_DATABASE_URL=mysql+pymysql://user:password@host:3306/signalscout?cha
SIGNALSCOUT_LLM_BASE_URL=https://api.zayuapi.com/v1 SIGNALSCOUT_LLM_BASE_URL=https://api.zayuapi.com/v1
SIGNALSCOUT_LLM_API_KEY=你的中转站 key SIGNALSCOUT_LLM_API_KEY=你的中转站 key
SIGNALSCOUT_LLM_MODEL=grok-4.3 SIGNALSCOUT_LLM_MODEL=grok-4.3
SIGNALSCOUT_LLM_MAX_TOKENS=8000
SIGNALSCOUT_NEWS_RECENT_DAYS=3 SIGNALSCOUT_NEWS_RECENT_DAYS=3
SIGNALSCOUT_NEWS_MAX_ITEMS=12 SIGNALSCOUT_NEWS_MAX_ITEMS=60
SIGNALSCOUT_NEWS_SEARCH_MAX_RECORDS=20 SIGNALSCOUT_NEWS_SEARCH_MAX_RECORDS=100
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=40
SIGNALSCOUT_AGENT_INTERVAL_MINUTES=30 SIGNALSCOUT_AGENT_INTERVAL_MINUTES=30
``` ```
单次运行的目标上限是新闻 60 条、GitHub 项目 40 条;实际入库数量会根据候选质量、链接去重和模型筛选结果浮动。
## 本机运行 ## 本机运行
按项目规则,本机 Python 使用: 按项目规则,本机 Python 使用:
@@ -66,7 +69,7 @@ http://127.0.0.1:8010/docs
- `GET /health`:服务健康检查 - `GET /health`:服务健康检查
- `GET /agent/runs`:查看最近运行记录 - `GET /agent/runs`:查看最近运行记录
- `GET /signals`:查看结构化情报 - `GET /signals`:查看结构化情报,可用 `source_type=news``source_type=github` 查看单类信号
- `GET /reports/latest`:查看最新日报 - `GET /reports/latest`:查看最新日报
- `GET /dashboard`:获取仪表盘数据 - `GET /dashboard`:获取仪表盘数据
- `POST /topics`:新增 Agent 跟踪主题 - `POST /topics`:新增 Agent 跟踪主题
+28 -7
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional from typing import Literal, Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -42,11 +42,19 @@ def list_runs(db: Session = Depends(get_db)) -> list[AgentRun]:
@router.get("/signals", response_model=list[SignalRead]) @router.get("/signals", response_model=list[SignalRead])
def list_signals(limit: int = 50, db: Session = Depends(get_db)) -> list[Signal]: def list_signals(
limit: int = 100,
source_type: Optional[Literal["news", "github"]] = None,
db: Session = Depends(get_db),
) -> list[Signal]:
# 情报列表按采集时间倒序返回,是仪表盘的主要阅读入口。 # 情报列表按采集时间倒序返回,是仪表盘的主要阅读入口。
safe_limit = max(1, min(limit, 200)) safe_limit = max(1, min(limit, 200))
return list( return list(
db.scalars(_visible_signals_query().order_by(Signal.id.desc()).limit(safe_limit)).all() db.scalars(
_visible_signals_query(source_type=source_type)
.order_by(Signal.id.desc())
.limit(safe_limit)
).all()
) )
@@ -60,12 +68,22 @@ def latest_report(db: Session = Depends(get_db)) -> Optional[Report]:
def dashboard(db: Session = Depends(get_db)) -> DashboardRead: def dashboard(db: Session = Depends(get_db)) -> DashboardRead:
# 单个仪表盘接口返回运行、信号、日报和主题,前端不需要理解内部调度细节。 # 单个仪表盘接口返回运行、信号、日报和主题,前端不需要理解内部调度细节。
runs = list(db.scalars(select(AgentRun).order_by(AgentRun.id.desc()).limit(10)).all()) runs = list(db.scalars(select(AgentRun).order_by(AgentRun.id.desc()).limit(10)).all())
signals = list(db.scalars(_visible_signals_query().order_by(Signal.id.desc()).limit(80)).all()) news_signals = list(
db.scalars(
_visible_signals_query(source_type="news").order_by(Signal.id.desc()).limit(80)
).all()
)
github_signals = list(
db.scalars(
_visible_signals_query(source_type="github").order_by(Signal.id.desc()).limit(80)
).all()
)
report = db.scalar(select(Report).order_by(Report.id.desc()).limit(1)) report = db.scalar(select(Report).order_by(Report.id.desc()).limit(1))
topics = list(db.scalars(select(Topic).order_by(Topic.id.asc())).all()) topics = list(db.scalars(select(Topic).order_by(Topic.id.asc())).all())
return DashboardRead( return DashboardRead(
runs=runs, runs=runs,
signals=signals, news_signals=news_signals,
github_signals=github_signals,
latest_report=report, latest_report=report,
topics=topics, topics=topics,
) )
@@ -96,6 +114,9 @@ def list_topics(db: Session = Depends(get_db)) -> list[Topic]:
return list(db.scalars(select(Topic).order_by(Topic.id.asc())).all()) return list(db.scalars(select(Topic).order_by(Topic.id.asc())).all())
def _visible_signals_query(): def _visible_signals_query(source_type: Optional[Literal["news", "github"]] = None):
# 运行中的信号和完成后的信号走同一张表,查询函数保留统一排序入口。 # 运行中的信号和完成后的信号走同一张表,查询函数保留统一排序入口。
return select(Signal).join(AgentRun, Signal.run_id == AgentRun.id) query = select(Signal).join(AgentRun, Signal.run_id == AgentRun.id)
if source_type is not None:
query = query.where(Signal.source_type == source_type)
return query
+4 -4
View File
@@ -16,13 +16,13 @@ class Settings(BaseSettings):
llm_api_key: str = "" llm_api_key: str = ""
llm_model: str = "grok-4.3" llm_model: str = "grok-4.3"
llm_timeout_seconds: int = 90 llm_timeout_seconds: int = 90
llm_max_tokens: int = 4000 llm_max_tokens: int = 8000
news_recent_days: int = 3 news_recent_days: int = 3
news_max_items: int = 12 news_max_items: int = 60
news_search_max_records: int = 20 news_search_max_records: int = 100
github_recent_days: int = 30 github_recent_days: int = 30
github_min_stars: int = 20 github_min_stars: int = 20
github_max_projects: int = 5 github_max_projects: int = 40
agent_timezone: str = "Asia/Shanghai" agent_timezone: str = "Asia/Shanghai"
agent_interval_minutes: int = 30 agent_interval_minutes: int = 30
report_dir: Path = Path("reports") report_dir: Path = Path("reports")
+1
View File
@@ -44,6 +44,7 @@ class Signal(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
run_id: Mapped[int] = mapped_column(ForeignKey("agent_runs.id"), nullable=False, index=True) run_id: Mapped[int] = mapped_column(ForeignKey("agent_runs.id"), nullable=False, index=True)
source_type: Mapped[str] = mapped_column(String(20), nullable=False, default="news", index=True)
topic: Mapped[str] = mapped_column(String(120), nullable=False, index=True) topic: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(300), nullable=False) title: Mapped[str] = mapped_column(String(300), nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False) summary: Mapped[str] = mapped_column(Text, nullable=False)
+2
View File
@@ -57,6 +57,7 @@ class AgentRepository:
if existing and existing.run_id != run.id: if existing and existing.run_id != run.id:
return None return None
if existing: if existing:
existing.source_type = item.source_type
existing.topic = item.topic existing.topic = item.topic
existing.title = item.title existing.title = item.title
existing.summary = item.summary existing.summary = item.summary
@@ -68,6 +69,7 @@ class AgentRepository:
return existing return existing
signal = Signal( signal = Signal(
run_id=run.id, run_id=run.id,
source_type=item.source_type,
topic=item.topic, topic=item.topic,
title=item.title, title=item.title,
summary=item.summary, summary=item.summary,
+4 -1
View File
@@ -58,7 +58,9 @@ class GitHubHotProjectClient:
def _search(self, query: str) -> dict[str, Any]: def _search(self, query: str) -> dict[str, Any]:
# GitHub REST搜索直接返回排序后的仓库元数据,便于后续模型研判。 # GitHub REST搜索直接返回排序后的仓库元数据,便于后续模型研判。
url = f"{self.endpoint}?{urlencode({'q': query, 'sort': 'stars', 'order': 'desc', 'per_page': '10'})}" per_page = min(max(self.settings.github_max_projects * 2, 20), 100)
params = {"q": query, "sort": "stars", "order": "desc", "per_page": str(per_page)}
url = f"{self.endpoint}?{urlencode(params)}"
request = Request( request = Request(
url, url,
headers={ headers={
@@ -92,6 +94,7 @@ class GitHubHotProjectClient:
f"主要语言为 {language}" f"主要语言为 {language}"
) )
return SignalItem( return SignalItem(
source_type="github",
topic="GitHub 热门项目", topic="GitHub 热门项目",
title=full_name, title=full_name,
summary=summary, summary=summary,
+1
View File
@@ -84,6 +84,7 @@ class NewsSearchClient:
comments = int(story.get("num_comments") or 0) comments = int(story.get("num_comments") or 0)
published_at = self._parse_hn_date(story.get("created_at")) published_at = self._parse_hn_date(story.get("created_at"))
return SignalItem( return SignalItem(
source_type="news",
topic="AI 新闻候选", topic="AI 新闻候选",
title=title[:300], title=title[:300],
summary=f"Hacker News 最新技术讨论,作者 {author},约 {points} points、{comments} 条评论。", summary=f"Hacker News 最新技术讨论,作者 {author},约 {points} points、{comments} 条评论。",
+5
View File
@@ -132,6 +132,7 @@ class GrokIntelligenceClient:
] ]
github_payload = [ github_payload = [
{ {
"source_type": item.source_type,
"title": item.title, "title": item.title,
"summary": item.summary, "summary": item.summary,
"source_url": item.source_url, "source_url": item.source_url,
@@ -142,6 +143,7 @@ class GrokIntelligenceClient:
] ]
news_payload = [ news_payload = [
{ {
"source_type": item.source_type,
"title": item.title, "title": item.title,
"summary": item.summary, "summary": item.summary,
"source_url": item.source_url, "source_url": item.source_url,
@@ -170,6 +172,7 @@ class GrokIntelligenceClient:
"title": "字符串,日报标题", "title": "字符串,日报标题",
"signals": [ "signals": [
{ {
"source_type": "news 或 github;新闻候选必须为 newsGitHub 候选必须为 github",
"topic": "AI 新闻 / GitHub 热门项目 / 模型发布 / Agent 工具 / 融资动态等", "topic": "AI 新闻 / GitHub 热门项目 / 模型发布 / Agent 工具 / 融资动态等",
"title": "字符串", "title": "字符串",
"summary": "中文摘要,说明为什么重要", "summary": "中文摘要,说明为什么重要",
@@ -189,6 +192,8 @@ class GrokIntelligenceClient:
"新闻必须来自日期窗口内或接近日内发生的事件。", "新闻必须来自日期窗口内或接近日内发生的事件。",
"AI新闻只能从 news_candidates 中选择,不要新增候选外新闻。", "AI新闻只能从 news_candidates 中选择,不要新增候选外新闻。",
"GitHub 热门项目只能从 github_candidates 中选择,不要新增候选外仓库。", "GitHub 热门项目只能从 github_candidates 中选择,不要新增候选外仓库。",
"news 和 github 是两个独立栏目,分别尽量输出到对应上限,不要把 GitHub 项目写成新闻。",
"只有候选重复、链接不可验证或明显不相关时,才少于对应上限。",
"每条 signal 必须有真实 source_url。", "每条 signal 必须有真实 source_url。",
"report_markdown 只能基于 signals 写,不要加入 signals 外的新事实。", "report_markdown 只能基于 signals 写,不要加入 signals 外的新事实。",
"输出必须是可被 json.loads 解析的 JSON object。", "输出必须是可被 json.loads 解析的 JSON object。",
+5 -3
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Literal, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -14,6 +14,7 @@ class SearchTopic(BaseModel):
class SignalItem(BaseModel): class SignalItem(BaseModel):
# 模型产出的结构化情报会通过这个契约进入数据库和日报。 # 模型产出的结构化情报会通过这个契约进入数据库和日报。
source_type: Literal["news", "github"]
topic: str topic: str
title: str title: str
summary: str summary: str
@@ -51,6 +52,7 @@ class RunRead(BaseModel):
class SignalRead(BaseModel): class SignalRead(BaseModel):
id: int id: int
run_id: int run_id: int
source_type: Literal["news", "github"]
topic: str topic: str
title: str title: str
summary: str summary: str
@@ -77,7 +79,8 @@ class ReportRead(BaseModel):
class DashboardRead(BaseModel): class DashboardRead(BaseModel):
runs: list[RunRead] runs: list[RunRead]
signals: list[SignalRead] news_signals: list[SignalRead]
github_signals: list[SignalRead]
latest_report: Optional[ReportRead] latest_report: Optional[ReportRead]
topics: list["TopicRead"] topics: list["TopicRead"]
@@ -95,4 +98,3 @@ class TopicRead(BaseModel):
enabled: bool enabled: bool
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+188 -69
View File
@@ -77,9 +77,7 @@ HOME_PAGE_HTML = """
line-height: 1.1; line-height: 1.1;
} }
.brand span, .brand span {
.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;
@@ -110,20 +108,6 @@ HOME_PAGE_HTML = """
font-weight: 700; 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 {
padding: 28px; padding: 28px;
display: grid; display: grid;
@@ -155,7 +139,7 @@ HOME_PAGE_HTML = """
} }
.pulse { .pulse {
min-width: 184px; min-width: 220px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
padding: 12px 14px; padding: 12px 14px;
@@ -172,6 +156,11 @@ HOME_PAGE_HTML = """
line-height: 1.2; line-height: 1.2;
} }
.pulse span {
display: block;
margin-top: 5px;
}
.layout { .layout {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr); grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr);
@@ -189,7 +178,7 @@ HOME_PAGE_HTML = """
} }
.view[data-view="signals"].active { .view[data-view="signals"].active {
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr); grid-template-columns: minmax(0, 1.05fr) minmax(360px, 0.95fr);
} }
.panel { .panel {
@@ -282,7 +271,70 @@ HOME_PAGE_HTML = """
padding: 18px; padding: 18px;
min-height: 520px; min-height: 520px;
line-height: 1.75; line-height: 1.75;
white-space: pre-wrap; color: oklch(25% 0.028 210);
}
.report-doc {
display: grid;
gap: 14px;
max-width: 980px;
}
.report-doc h2,
.report-doc h3,
.report-doc h4,
.report-doc p,
.report-doc ul,
.report-doc ol {
margin: 0;
}
.report-doc h2 {
font-size: 24px;
line-height: 1.25;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.report-doc h3 {
margin-top: 8px;
font-size: 18px;
line-height: 1.35;
}
.report-doc h4 {
color: oklch(34% 0.035 190);
font-size: 15px;
line-height: 1.35;
}
.report-doc p {
color: oklch(30% 0.025 210);
}
.report-doc ul,
.report-doc ol {
display: grid;
gap: 8px;
padding-left: 22px;
}
.report-doc li {
padding-left: 3px;
}
.report-doc a {
color: oklch(42% 0.12 174);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.report-doc code {
border: 1px solid var(--line);
border-radius: 6px;
padding: 1px 5px;
background: oklch(96% 0.012 180);
font-size: 0.92em;
} }
.runs { display: grid; } .runs { display: grid; }
@@ -342,57 +394,52 @@ HOME_PAGE_HTML = """
<span>AI 情报采集与研判工作台</span> <span>AI 情报采集与研判工作台</span>
</div> </div>
<nav class="nav" aria-label="主导航"> <nav class="nav" aria-label="主导航">
<button class="active" type="button" data-view-target="signals">情报</button> <button class="active" type="button" data-view-target="signals">信号</button>
<button type="button" data-view-target="report">日报</button> <button type="button" data-view-target="report">日报</button>
<button type="button" data-view-target="runs">记录</button> <button type="button" data-view-target="runs">记录</button>
</nav> </nav>
<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">新闻动态与 GitHub 项目分开阅读,日报保留适合快速复盘的重点判断。</p>
</div> </div>
<div class="pulse"> <div class="pulse">
<strong id="lastRunText">等待运行</strong> <strong id="todayCountText">读取中</strong>
<span id="lastRunDetail">正在读取运行记录。</span> <span id="todayCountDetail">正在整理新闻与项目。</span>
</div> </div>
</section> </section>
<section class="view active" data-view="signals"> <section class="view active" data-view="signals">
<div class="panel"> <div class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>情报信号</h2> <h2>AI 新闻</h2>
<span class="count" id="signalCount">0 条</span> <span class="count" id="newsCount">0 条</span>
</div> </div>
<div class="signals" id="signalList"> <div class="signals" id="newsList">
<div class="empty">正在读取情报。</div> <div class="empty">正在读取新闻。</div>
</div> </div>
</div> </div>
<div class="stack"> <div class="stack">
<div class="panel"> <div class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>最新日报</h2> <h2>GitHub 热门项目</h2>
<span class="count" id="reportTime">待生成</span> <span class="count" id="githubCount">0 条</span>
</div>
<div class="signals" id="githubList">
<div class="empty">正在读取项目。</div>
</div> </div>
<div class="report" id="reportPreview">正在读取日报。</div>
</div> </div>
<div class="panel"> <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="reportTime">待生成</span>
</div>
<div class="runs" id="runPreview">
<div class="empty">正在读取运行记录。</div>
</div> </div>
<div class="report" id="reportPreview">正在读取日报。</div>
</div> </div>
</div> </div>
</section> </section>
@@ -410,7 +457,7 @@ HOME_PAGE_HTML = """
<section class="view" data-view="runs"> <section class="view" data-view="runs">
<div class="panel"> <div class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>自动记录</h2> <h2>运行记录</h2>
<span class="count" id="runCountFull">0 次</span> <span class="count" id="runCountFull">0 次</span>
</div> </div>
<div class="runs" id="runList"> <div class="runs" id="runList">
@@ -424,18 +471,18 @@ HOME_PAGE_HTML = """
<script> <script>
const navButtons = Array.from(document.querySelectorAll("[data-view-target]")); const navButtons = Array.from(document.querySelectorAll("[data-view-target]"));
const views = Array.from(document.querySelectorAll("[data-view]")); const views = Array.from(document.querySelectorAll("[data-view]"));
const signalList = document.querySelector("#signalList"); const newsList = document.querySelector("#newsList");
const signalCount = document.querySelector("#signalCount"); const newsCount = document.querySelector("#newsCount");
const githubList = document.querySelector("#githubList");
const githubCount = document.querySelector("#githubCount");
const reportPreview = document.querySelector("#reportPreview"); 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 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 runCountFull = document.querySelector("#runCountFull"); const runCountFull = document.querySelector("#runCountFull");
const lastRunText = document.querySelector("#lastRunText"); const todayCountText = document.querySelector("#todayCountText");
const lastRunDetail = document.querySelector("#lastRunDetail"); const todayCountDetail = document.querySelector("#todayCountDetail");
const stateText = { const stateText = {
completed: "完成", completed: "完成",
@@ -452,6 +499,67 @@ HOME_PAGE_HTML = """
.replaceAll("'", "&#039;"); .replaceAll("'", "&#039;");
} }
function renderInlineMarkdown(value) {
return escapeHtml(value)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\\*\\*([^*]+)\\*\\*/g, "<strong>$1</strong>")
.replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
}
function renderMarkdownReport(markdown) {
const lines = String(markdown || "").split(/\\r?\\n/);
const blocks = [];
let paragraph = [];
let listItems = [];
let listTag = "ul";
const flushParagraph = () => {
if (!paragraph.length) return;
blocks.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`);
paragraph = [];
};
const flushList = () => {
if (!listItems.length) return;
blocks.push(`<${listTag}>${listItems.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</${listTag}>`);
listItems = [];
};
lines.forEach((line) => {
const trimmed = line.trim();
const heading = /^(#{1,4})\\s+(.+)$/.exec(trimmed);
const bullet = /^[-*]\\s+(.+)$/.exec(trimmed);
const ordered = /^\\d+[.)]\\s+(.+)$/.exec(trimmed);
if (!trimmed) {
flushParagraph();
flushList();
return;
}
if (heading) {
flushParagraph();
flushList();
const level = Math.min(heading[1].length + 1, 4);
blocks.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
return;
}
if (bullet || ordered) {
flushParagraph();
const nextTag = ordered ? "ol" : "ul";
if (listItems.length && listTag !== nextTag) flushList();
listTag = nextTag;
listItems.push((bullet || ordered)[1]);
return;
}
flushList();
paragraph.push(trimmed);
});
flushParagraph();
flushList();
return `<div class="report-doc">${blocks.join("")}</div>`;
}
function formatDate(value) { function formatDate(value) {
if (!value) return "未标注时间"; if (!value) return "未标注时间";
const text = String(value); const text = String(value);
@@ -473,13 +581,13 @@ HOME_PAGE_HTML = """
if (updateHash) history.replaceState(null, "", `#${name}`); if (updateHash) history.replaceState(null, "", `#${name}`);
} }
function renderSignals(signals) { function renderSignalGroup(signals, target, counter, emptyText) {
signalCount.textContent = `${signals.length} 条`; counter.textContent = `${signals.length} 条`;
if (!signals.length) { if (!signals.length) {
signalList.innerHTML = '<div class="empty">Agent 正在按固定节奏自动整理,完成后这里会出现情报信号。</div>'; target.innerHTML = `<div class="empty">${escapeHtml(emptyText)}</div>`;
return; return;
} }
signalList.innerHTML = signals.map((item) => ` target.innerHTML = signals.map((item) => `
<article class="signal-row"> <article class="signal-row">
<div class="signal-meta"> <div class="signal-meta">
<span class="pill">${escapeHtml(item.topic)}</span> <span class="pill">${escapeHtml(item.topic)}</span>
@@ -500,16 +608,16 @@ HOME_PAGE_HTML = """
if (!report) { if (!report) {
reportTime.textContent = "待生成"; reportTime.textContent = "待生成";
reportTimeFull.textContent = "待生成"; reportTimeFull.textContent = "待生成";
reportPreview.textContent = "Agent 正在自动整理,日报生成后会显示在这里。"; reportPreview.textContent = "日报生成后会显示在这里。";
reportBody.textContent = "Agent 正在自动整理,日报生成后会显示在这里。"; reportBody.textContent = "日报生成后会显示在这里。";
return; return;
} }
const timeText = formatDate(report.created_at); const timeText = formatDate(report.created_at);
const content = report.content_md || "日报内容为空。"; const content = report.content_md || "日报内容为空。";
reportTime.textContent = timeText; reportTime.textContent = timeText;
reportTimeFull.textContent = timeText; reportTimeFull.textContent = timeText;
reportPreview.textContent = content; reportPreview.innerHTML = renderMarkdownReport(content);
reportBody.textContent = content; reportBody.innerHTML = renderMarkdownReport(content);
} }
function runRows(runs) { function runRows(runs) {
@@ -522,27 +630,38 @@ HOME_PAGE_HTML = """
} }
function renderRuns(runs) { function renderRuns(runs) {
runCount.textContent = `${runs.length} 次`;
runCountFull.textContent = `${runs.length} 次`; runCountFull.textContent = `${runs.length} 次`;
if (!runs.length) { if (!runs.length) {
runPreview.innerHTML = '<div class="empty">Agent 会按固定节奏自动整理。</div>'; runList.innerHTML = '<div class="empty">暂无运行记录。</div>';
runList.innerHTML = '<div class="empty">Agent 会按固定节奏自动整理。</div>';
lastRunText.textContent = "等待记录";
lastRunDetail.textContent = "约每 30 分钟自动整理一次。";
return; return;
} }
runPreview.innerHTML = runRows(runs.slice(0, 4));
runList.innerHTML = runRows(runs); runList.innerHTML = runRows(runs);
const latest = runs[0]; }
lastRunText.textContent = stateText[latest.status] || latest.status;
lastRunDetail.textContent = `最近一次:${formatDate(latest.started_at)}`; function renderSignalSummary(newsSignals, githubSignals) {
const newsTotal = newsSignals.length;
const githubTotal = githubSignals.length;
todayCountText.textContent = `${newsTotal + githubTotal} 条信号`;
todayCountDetail.textContent = `新闻 ${newsTotal} · GitHub ${githubTotal}`;
} }
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("读取情报失败");
const data = await response.json(); const data = await response.json();
renderSignals(data.signals || []); renderSignalGroup(
data.news_signals || [],
newsList,
newsCount,
"Agent 正在整理 AI 新闻,完成后这里会出现新闻信号。"
);
renderSignalGroup(
data.github_signals || [],
githubList,
githubCount,
"Agent 正在整理 GitHub 热门项目,完成后这里会出现项目线索。"
);
renderSignalSummary(data.news_signals || [], data.github_signals || []);
renderReport(data.latest_report); renderReport(data.latest_report);
renderRuns(data.runs || []); renderRuns(data.runs || []);
} }
@@ -557,10 +676,10 @@ HOME_PAGE_HTML = """
} }
loadDashboard().catch(() => { loadDashboard().catch(() => {
signalList.innerHTML = '<div class="empty">情报读取失败,请稍后再试。</div>'; newsList.innerHTML = '<div class="empty">新闻读取失败,请稍后再试。</div>';
githubList.innerHTML = '<div class="empty">项目读取失败,请稍后再试。</div>';
reportPreview.textContent = "日报读取失败。"; 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); window.setInterval(loadDashboard, 60000);
@@ -0,0 +1,32 @@
"""add signal source type
Revision ID: 202607081700
Revises: 202607081530
Create Date: 2026-07-08 17:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "202607081700"
down_revision: Union[str, None] = "202607081530"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# source_type让新闻和GitHub项目拥有稳定分类,仪表盘和API都按这个字段分组。
op.add_column(
"signals",
sa.Column("source_type", sa.String(length=20), nullable=False, server_default="news"),
)
op.execute("UPDATE signals SET source_type = 'github' WHERE source_name = 'GitHub'")
op.create_index("ix_signals_source_type", "signals", ["source_type"])
def downgrade() -> None:
# 回滚时移除分类索引和字段,恢复迁移前的signals结构。
op.drop_index("ix_signals_source_type", table_name="signals")
op.drop_column("signals", "source_type")
+82 -1
View File
@@ -1,6 +1,13 @@
from datetime import datetime
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.config import get_settings from app.core.config import get_settings
from app.db.models import AgentRun, Base, Signal
from app.db.session import get_db
from app.main import create_app from app.main import create_app
@@ -27,7 +34,9 @@ def test_home_page_returns_dashboard_shell(monkeypatch) -> None:
assert response.status_code == 200 assert response.status_code == 200
assert "SignalScout" in response.text assert "SignalScout" in response.text
assert "今日 AI 情报" in response.text assert "今日 AI 信号" in response.text
assert "renderMarkdownReport" in response.text
assert "自动记录" not in response.text
assert "采集今日情报" not in response.text assert "采集今日情报" not in response.text
@@ -42,3 +51,75 @@ def test_manual_agent_run_endpoint_is_not_exposed(monkeypatch) -> None:
assert response.status_code == 404 assert response.status_code == 404
assert stream_response.status_code == 404 assert stream_response.status_code == 404
def test_dashboard_returns_news_and_github_signals_separately(monkeypatch) -> None:
# 仪表盘接口直接返回分组后的业务数据,前端不再用标题或来源名自行猜测分类。
monkeypatch.setenv("SIGNALSCOUT_ENV", "test")
get_settings.cache_clear()
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
future=True,
)
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
Base.metadata.create_all(engine)
with TestingSessionLocal() as session:
run = AgentRun(status="completed", started_at=datetime(2026, 7, 8, 8, 0), request_payload={})
session.add(run)
session.flush()
session.add_all(
[
Signal(
run_id=run.id,
source_type="news",
topic="AI 新闻",
title="New model release",
summary="模型发布动态。",
source_url="https://example.com/news",
source_url_hash="news-hash",
source_name="Example News",
published_at=datetime(2026, 7, 8, 9, 0),
importance=4,
entities=["Example"],
),
Signal(
run_id=run.id,
source_type="github",
topic="GitHub 热门项目",
title="example/agent-kit",
summary="开源项目动态。",
source_url="https://github.com/example/agent-kit",
source_url_hash="github-hash",
source_name="GitHub",
published_at=datetime(2026, 7, 8, 10, 0),
importance=5,
entities=["example/agent-kit"],
),
]
)
session.commit()
def override_get_db():
# 测试依赖把接口请求限制在内存库里,避免触碰开发数据库。
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app = create_app()
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as client:
dashboard_response = client.get("/dashboard")
github_response = client.get("/signals", params={"source_type": "github"})
get_settings.cache_clear()
dashboard = dashboard_response.json()
assert dashboard_response.status_code == 200
assert [item["source_type"] for item in dashboard["news_signals"]] == ["news"]
assert [item["source_type"] for item in dashboard["github_signals"]] == ["github"]
assert github_response.status_code == 200
assert [item["title"] for item in github_response.json()] == ["example/agent-kit"]
+1
View File
@@ -19,6 +19,7 @@ def test_github_client_maps_repository_to_signal() -> None:
) )
assert signal.topic == "GitHub 热门项目" assert signal.topic == "GitHub 热门项目"
assert signal.source_type == "github"
assert signal.source_name == "GitHub" assert signal.source_name == "GitHub"
assert signal.source_url == "https://github.com/example/agent-kit" assert signal.source_url == "https://github.com/example/agent-kit"
assert "2400 stars" in signal.summary assert "2400 stars" in signal.summary
+6
View File
@@ -10,10 +10,13 @@ class StubGrokClient(GrokIntelligenceClient):
def _chat_json(self, messages): def _chat_json(self, messages):
# 测试只验证提示词输入和结果契约,不访问真实模型服务。 # 测试只验证提示词输入和结果契约,不访问真实模型服务。
assert "github_candidates" in messages[1]["content"] assert "github_candidates" in messages[1]["content"]
assert '"source_type": "news"' in messages[1]["content"]
assert '"source_type": "github"' in messages[1]["content"]
return { return {
"title": "AI 情报日报 2026-07-08", "title": "AI 情报日报 2026-07-08",
"signals": [ "signals": [
{ {
"source_type": "github",
"topic": "GitHub 热门项目", "topic": "GitHub 热门项目",
"title": "example/agent-kit", "title": "example/agent-kit",
"summary": "该项目提供生产级 Agent 工作流能力。", "summary": "该项目提供生产级 Agent 工作流能力。",
@@ -43,6 +46,7 @@ def test_grok_client_returns_agent_result() -> None:
], ],
news_candidates=[ news_candidates=[
SignalItem( SignalItem(
source_type="news",
topic="AI 新闻候选", topic="AI 新闻候选",
title="New model release", title="New model release",
summary="News candidate from example.com.", summary="News candidate from example.com.",
@@ -55,6 +59,7 @@ def test_grok_client_returns_agent_result() -> None:
], ],
github_projects=[ github_projects=[
SignalItem( SignalItem(
source_type="github",
topic="GitHub 热门项目", topic="GitHub 热门项目",
title="example/agent-kit", title="example/agent-kit",
summary="Agent framework.", summary="Agent framework.",
@@ -69,5 +74,6 @@ def test_grok_client_returns_agent_result() -> None:
) )
assert result.title == "AI 情报日报 2026-07-08" assert result.title == "AI 情报日报 2026-07-08"
assert result.signals[0].source_type == "github"
assert result.signals[0].source_name == "GitHub" assert result.signals[0].source_name == "GitHub"
assert result.report_markdown.startswith("# AI 情报日报") assert result.report_markdown.startswith("# AI 情报日报")
+1
View File
@@ -19,6 +19,7 @@ def test_news_search_client_maps_article_to_candidate() -> None:
assert signal is not None assert signal is not None
assert signal.topic == "AI 新闻候选" assert signal.topic == "AI 新闻候选"
assert signal.source_type == "news"
assert signal.source_name == "Hacker News" assert signal.source_name == "Hacker News"
assert signal.published_at is not None assert signal.published_at is not None
assert signal.published_at.year == 2026 assert signal.published_at.year == 2026