优化情报分栏和日报展示

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
+28 -7
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Optional
from typing import Literal, Optional
from fastapi import APIRouter, Depends
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])
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))
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:
# 单个仪表盘接口返回运行、信号、日报和主题,前端不需要理解内部调度细节。
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))
topics = list(db.scalars(select(Topic).order_by(Topic.id.asc())).all())
return DashboardRead(
runs=runs,
signals=signals,
news_signals=news_signals,
github_signals=github_signals,
latest_report=report,
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())
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_model: str = "grok-4.3"
llm_timeout_seconds: int = 90
llm_max_tokens: int = 4000
llm_max_tokens: int = 8000
news_recent_days: int = 3
news_max_items: int = 12
news_search_max_records: int = 20
news_max_items: int = 60
news_search_max_records: int = 100
github_recent_days: int = 30
github_min_stars: int = 20
github_max_projects: int = 5
github_max_projects: int = 40
agent_timezone: str = "Asia/Shanghai"
agent_interval_minutes: int = 30
report_dir: Path = Path("reports")
+1
View File
@@ -44,6 +44,7 @@ class Signal(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=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)
title: Mapped[str] = mapped_column(String(300), 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:
return None
if existing:
existing.source_type = item.source_type
existing.topic = item.topic
existing.title = item.title
existing.summary = item.summary
@@ -68,6 +69,7 @@ class AgentRepository:
return existing
signal = Signal(
run_id=run.id,
source_type=item.source_type,
topic=item.topic,
title=item.title,
summary=item.summary,
+4 -1
View File
@@ -58,7 +58,9 @@ class GitHubHotProjectClient:
def _search(self, query: str) -> dict[str, Any]:
# 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(
url,
headers={
@@ -92,6 +94,7 @@ class GitHubHotProjectClient:
f"主要语言为 {language}"
)
return SignalItem(
source_type="github",
topic="GitHub 热门项目",
title=full_name,
summary=summary,
+1
View File
@@ -84,6 +84,7 @@ class NewsSearchClient:
comments = int(story.get("num_comments") or 0)
published_at = self._parse_hn_date(story.get("created_at"))
return SignalItem(
source_type="news",
topic="AI 新闻候选",
title=title[:300],
summary=f"Hacker News 最新技术讨论,作者 {author},约 {points} points、{comments} 条评论。",
+5
View File
@@ -132,6 +132,7 @@ class GrokIntelligenceClient:
]
github_payload = [
{
"source_type": item.source_type,
"title": item.title,
"summary": item.summary,
"source_url": item.source_url,
@@ -142,6 +143,7 @@ class GrokIntelligenceClient:
]
news_payload = [
{
"source_type": item.source_type,
"title": item.title,
"summary": item.summary,
"source_url": item.source_url,
@@ -170,6 +172,7 @@ class GrokIntelligenceClient:
"title": "字符串,日报标题",
"signals": [
{
"source_type": "news 或 github;新闻候选必须为 newsGitHub 候选必须为 github",
"topic": "AI 新闻 / GitHub 热门项目 / 模型发布 / Agent 工具 / 融资动态等",
"title": "字符串",
"summary": "中文摘要,说明为什么重要",
@@ -189,6 +192,8 @@ class GrokIntelligenceClient:
"新闻必须来自日期窗口内或接近日内发生的事件。",
"AI新闻只能从 news_candidates 中选择,不要新增候选外新闻。",
"GitHub 热门项目只能从 github_candidates 中选择,不要新增候选外仓库。",
"news 和 github 是两个独立栏目,分别尽量输出到对应上限,不要把 GitHub 项目写成新闻。",
"只有候选重复、链接不可验证或明显不相关时,才少于对应上限。",
"每条 signal 必须有真实 source_url。",
"report_markdown 只能基于 signals 写,不要加入 signals 外的新事实。",
"输出必须是可被 json.loads 解析的 JSON object。",
+5 -3
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from typing import Literal, Optional
from pydantic import BaseModel, Field
@@ -14,6 +14,7 @@ class SearchTopic(BaseModel):
class SignalItem(BaseModel):
# 模型产出的结构化情报会通过这个契约进入数据库和日报。
source_type: Literal["news", "github"]
topic: str
title: str
summary: str
@@ -51,6 +52,7 @@ class RunRead(BaseModel):
class SignalRead(BaseModel):
id: int
run_id: int
source_type: Literal["news", "github"]
topic: str
title: str
summary: str
@@ -77,7 +79,8 @@ class ReportRead(BaseModel):
class DashboardRead(BaseModel):
runs: list[RunRead]
signals: list[SignalRead]
news_signals: list[SignalRead]
github_signals: list[SignalRead]
latest_report: Optional[ReportRead]
topics: list["TopicRead"]
@@ -95,4 +98,3 @@ class TopicRead(BaseModel):
enabled: bool
model_config = {"from_attributes": True}
+188 -69
View File
@@ -77,9 +77,7 @@ HOME_PAGE_HTML = """
line-height: 1.1;
}
.brand span,
.nav-note,
.auto-card span {
.brand span {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
@@ -110,20 +108,6 @@ HOME_PAGE_HTML = """
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 {
padding: 28px;
display: grid;
@@ -155,7 +139,7 @@ HOME_PAGE_HTML = """
}
.pulse {
min-width: 184px;
min-width: 220px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px 14px;
@@ -172,6 +156,11 @@ HOME_PAGE_HTML = """
line-height: 1.2;
}
.pulse span {
display: block;
margin-top: 5px;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr);
@@ -189,7 +178,7 @@ HOME_PAGE_HTML = """
}
.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 {
@@ -282,7 +271,70 @@ HOME_PAGE_HTML = """
padding: 18px;
min-height: 520px;
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; }
@@ -342,57 +394,52 @@ HOME_PAGE_HTML = """
<span>AI 情报采集与研判工作台</span>
</div>
<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="runs">记录</button>
</nav>
<div class="auto-card">
<strong>自动整理</strong>
<span>约每 30 分钟自动整理一次。</span>
</div>
<p class="nav-note">聚合 AI 新闻、模型发布、Agent 工具和 GitHub 热门项目。</p>
</aside>
<main class="content">
<section class="topbar">
<div>
<h1>今日 AI 情报</h1>
<p class="subtitle">Agent 自动筛选、去重和生成日报;页面会持续更新最新结果。</p>
<h1>今日 AI 信号</h1>
<p class="subtitle">新闻动态与 GitHub 项目分开阅读,日报保留适合快速复盘的重点判断。</p>
</div>
<div class="pulse">
<strong id="lastRunText">等待运行</strong>
<span id="lastRunDetail">正在读取运行记录。</span>
<strong id="todayCountText">读取中</strong>
<span id="todayCountDetail">正在整理新闻与项目。</span>
</div>
</section>
<section class="view active" data-view="signals">
<div class="panel">
<div class="panel-head">
<h2>情报信号</h2>
<span class="count" id="signalCount">0 条</span>
<h2>AI 新闻</h2>
<span class="count" id="newsCount">0 条</span>
</div>
<div class="signals" id="signalList">
<div class="empty">正在读取情报。</div>
<div class="signals" id="newsList">
<div class="empty">正在读取新闻。</div>
</div>
</div>
<div class="stack">
<div class="panel">
<div class="panel-head">
<h2>最新日报</h2>
<span class="count" id="reportTime">待生成</span>
<h2>GitHub 热门项目</h2>
<span class="count" id="githubCount">0 条</span>
</div>
<div class="signals" id="githubList">
<div class="empty">正在读取项目。</div>
</div>
<div class="report" id="reportPreview">正在读取日报。</div>
</div>
<div class="panel">
<div class="panel-head">
<h2>自动记录</h2>
<span class="count" id="runCount">0 次</span>
</div>
<div class="runs" id="runPreview">
<div class="empty">正在读取运行记录。</div>
<h2>最新日报</h2>
<span class="count" id="reportTime">待生成</span>
</div>
<div class="report" id="reportPreview">正在读取日报。</div>
</div>
</div>
</section>
@@ -410,7 +457,7 @@ HOME_PAGE_HTML = """
<section class="view" data-view="runs">
<div class="panel">
<div class="panel-head">
<h2>自动记录</h2>
<h2>运行记录</h2>
<span class="count" id="runCountFull">0 次</span>
</div>
<div class="runs" id="runList">
@@ -424,18 +471,18 @@ HOME_PAGE_HTML = """
<script>
const navButtons = Array.from(document.querySelectorAll("[data-view-target]"));
const views = Array.from(document.querySelectorAll("[data-view]"));
const signalList = document.querySelector("#signalList");
const signalCount = document.querySelector("#signalCount");
const newsList = document.querySelector("#newsList");
const newsCount = document.querySelector("#newsCount");
const githubList = document.querySelector("#githubList");
const githubCount = document.querySelector("#githubCount");
const reportPreview = document.querySelector("#reportPreview");
const reportBody = document.querySelector("#reportBody");
const reportTime = document.querySelector("#reportTime");
const reportTimeFull = document.querySelector("#reportTimeFull");
const runPreview = document.querySelector("#runPreview");
const runList = document.querySelector("#runList");
const runCount = document.querySelector("#runCount");
const runCountFull = document.querySelector("#runCountFull");
const lastRunText = document.querySelector("#lastRunText");
const lastRunDetail = document.querySelector("#lastRunDetail");
const todayCountText = document.querySelector("#todayCountText");
const todayCountDetail = document.querySelector("#todayCountDetail");
const stateText = {
completed: "完成",
@@ -452,6 +499,67 @@ HOME_PAGE_HTML = """
.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) {
if (!value) return "未标注时间";
const text = String(value);
@@ -473,13 +581,13 @@ HOME_PAGE_HTML = """
if (updateHash) history.replaceState(null, "", `#${name}`);
}
function renderSignals(signals) {
signalCount.textContent = `${signals.length} 条`;
function renderSignalGroup(signals, target, counter, emptyText) {
counter.textContent = `${signals.length} 条`;
if (!signals.length) {
signalList.innerHTML = '<div class="empty">Agent 正在按固定节奏自动整理,完成后这里会出现情报信号。</div>';
target.innerHTML = `<div class="empty">${escapeHtml(emptyText)}</div>`;
return;
}
signalList.innerHTML = signals.map((item) => `
target.innerHTML = signals.map((item) => `
<article class="signal-row">
<div class="signal-meta">
<span class="pill">${escapeHtml(item.topic)}</span>
@@ -500,16 +608,16 @@ HOME_PAGE_HTML = """
if (!report) {
reportTime.textContent = "待生成";
reportTimeFull.textContent = "待生成";
reportPreview.textContent = "Agent 正在自动整理,日报生成后会显示在这里。";
reportBody.textContent = "Agent 正在自动整理,日报生成后会显示在这里。";
reportPreview.textContent = "日报生成后会显示在这里。";
reportBody.textContent = "日报生成后会显示在这里。";
return;
}
const timeText = formatDate(report.created_at);
const content = report.content_md || "日报内容为空。";
reportTime.textContent = timeText;
reportTimeFull.textContent = timeText;
reportPreview.textContent = content;
reportBody.textContent = content;
reportPreview.innerHTML = renderMarkdownReport(content);
reportBody.innerHTML = renderMarkdownReport(content);
}
function runRows(runs) {
@@ -522,27 +630,38 @@ HOME_PAGE_HTML = """
}
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 分钟自动整理一次。";
runList.innerHTML = '<div class="empty">暂无运行记录。</div>';
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)}`;
}
function renderSignalSummary(newsSignals, githubSignals) {
const newsTotal = newsSignals.length;
const githubTotal = githubSignals.length;
todayCountText.textContent = `${newsTotal + githubTotal} 条信号`;
todayCountDetail.textContent = `新闻 ${newsTotal} · GitHub ${githubTotal}`;
}
async function loadDashboard() {
const response = await fetch("/dashboard", { headers: { Accept: "application/json" } });
if (!response.ok) throw new Error("读取情报失败");
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);
renderRuns(data.runs || []);
}
@@ -557,10 +676,10 @@ HOME_PAGE_HTML = """
}
loadDashboard().catch(() => {
signalList.innerHTML = '<div class="empty">情报读取失败,请稍后再试。</div>';
newsList.innerHTML = '<div class="empty">新闻读取失败,请稍后再试。</div>';
githubList.innerHTML = '<div class="empty">项目读取失败,请稍后再试。</div>';
reportPreview.textContent = "日报读取失败。";
reportBody.textContent = "日报读取失败。";
runPreview.innerHTML = '<div class="empty">运行记录读取失败。</div>';
runList.innerHTML = '<div class="empty">运行记录读取失败。</div>';
});
window.setInterval(loadDashboard, 60000);