From 88e1cf4b7a3d850aee2ad9145e28b472cbf2c752 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 8 Jul 2026 23:08:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=8C=97=E4=BA=AC=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E5=B9=B6=E6=9B=B4=E6=8D=A2=E6=96=B0=E9=97=BB=E5=80=99?= =?UTF-8?q?=E9=80=89=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/runner.py | 3 +- app/core/timezone.py | 22 +++++ app/db/repository.py | 17 +++- app/integrations/github_client.py | 5 +- app/integrations/news_search_client.py | 95 +++++++++++-------- app/ui/home.py | 5 +- .../202607081530_store_business_time.py | 45 +++++++++ tests/test_github_client.py | 2 + tests/test_news_search_client.py | 16 ++-- tests/test_timezone.py | 23 +++++ 10 files changed, 178 insertions(+), 55 deletions(-) create mode 100644 app/core/timezone.py create mode 100644 migrations/versions/202607081530_store_business_time.py create mode 100644 tests/test_timezone.py diff --git a/app/agent/runner.py b/app/agent/runner.py index 0a66895..15e0d26 100644 --- a/app/agent/runner.py +++ b/app/agent/runner.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from app.agent.report_writer import ReportWriter from app.core.config import Settings +from app.core.timezone import beijing_today from app.db.repository import AgentRepository from app.integrations.github_client import GitHubHotProjectClient from app.integrations.news_search_client import NewsSearchClient @@ -30,7 +31,7 @@ class AgentRunner: def run_daily(self, run_date: Optional[date] = None) -> RunResponse: # Runner统一编排候选采集、模型研判和持久化,保证一次运行只有一种业务路径。 - current_date = run_date or date.today() + current_date = run_date or beijing_today() request_payload = { "run_date": current_date.isoformat(), "mode": "grok_agent", diff --git a/app/core/timezone.py b/app/core/timezone.py new file mode 100644 index 0000000..dda9505 --- /dev/null +++ b/app/core/timezone.py @@ -0,0 +1,22 @@ +from datetime import datetime +from datetime import date +from zoneinfo import ZoneInfo + +BEIJING_TZ = ZoneInfo("Asia/Shanghai") + + +def beijing_now() -> datetime: + # 数据库字段使用无时区DateTime,写入前统一转换成北京时间,避免UTC时间直接显示到业务界面。 + return datetime.now(BEIJING_TZ).replace(tzinfo=None) + + +def beijing_today() -> date: + # Agent日报的业务日期按北京时间计算,避免UTC服务器在清晨生成前一天日报。 + return datetime.now(BEIJING_TZ).date() + + +def to_beijing_naive(value: datetime) -> datetime: + # 外部接口常返回带时区时间,入库前统一换算为北京时间的无时区值。 + if value.tzinfo is None: + return value + return value.astimezone(BEIJING_TZ).replace(tzinfo=None) diff --git a/app/db/repository.py b/app/db/repository.py index ec68f78..9178d59 100644 --- a/app/db/repository.py +++ b/app/db/repository.py @@ -1,12 +1,12 @@ from __future__ import annotations -from datetime import datetime from hashlib import sha256 from typing import Optional from sqlalchemy import select from sqlalchemy.orm import Session +from app.core.timezone import beijing_now from app.db.models import AgentRun, Report, Signal, Topic from app.schemas.agent import SignalItem @@ -23,20 +23,20 @@ class AgentRepository: def create_run(self, request_payload: dict) -> AgentRun: # 运行先进入running状态,情报和日报保存完成后再标记完成。 - run = AgentRun(status="running", request_payload=request_payload) + run = AgentRun(status="running", started_at=beijing_now(), request_payload=request_payload) self.db.add(run) self.db.flush() return run def mark_run_complete(self, run: AgentRun, raw_response: dict) -> None: run.status = "completed" - run.finished_at = datetime.utcnow() + run.finished_at = beijing_now() run.raw_response = raw_response self.db.flush() def mark_run_failed(self, run: AgentRun, error: str) -> None: run.status = "failed" - run.finished_at = datetime.utcnow() + run.finished_at = beijing_now() run.error = error self.db.flush() @@ -77,6 +77,7 @@ class AgentRepository: published_at=item.published_at, importance=item.importance, entities=item.entities, + created_at=beijing_now(), ) self.db.add(signal) self.db.flush() @@ -88,7 +89,13 @@ class AgentRepository: def save_report(self, run: AgentRun, title: str, content_md: str, file_path: str) -> Report: # 文件路径便于人工打开Markdown产物,MySQL保存可查询正文。 - report = Report(run_id=run.id, title=title, content_md=content_md, file_path=file_path) + report = Report( + run_id=run.id, + title=title, + content_md=content_md, + file_path=file_path, + created_at=beijing_now(), + ) self.db.add(report) self.db.flush() return report diff --git a/app/integrations/github_client.py b/app/integrations/github_client.py index c1c371f..44de3eb 100644 --- a/app/integrations/github_client.py +++ b/app/integrations/github_client.py @@ -9,6 +9,7 @@ from urllib.parse import urlencode from urllib.request import Request, urlopen from app.core.config import Settings +from app.core.timezone import to_beijing_naive from app.schemas.agent import SignalItem logger = logging.getLogger(__name__) @@ -110,7 +111,7 @@ class GitHubHotProjectClient: return stars * 3 + forks + recency_bonus def _parse_datetime(self, value: Any) -> Optional[datetime]: - # GitHub时间戳使用UTC ISO格式和Z后缀。 + # GitHub时间戳使用UTC ISO格式和Z后缀,入库前先换算为北京时间。 if not isinstance(value, str): return None - return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) + return to_beijing_naive(datetime.fromisoformat(value.replace("Z", "+00:00"))) diff --git a/app/integrations/news_search_client.py b/app/integrations/news_search_client.py index c88746d..d9032a2 100644 --- a/app/integrations/news_search_client.py +++ b/app/integrations/news_search_client.py @@ -2,65 +2,69 @@ from __future__ import annotations import json import logging -from datetime import date, datetime, timedelta +from datetime import date, datetime, time, timedelta from typing import Any, Optional from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen from app.core.config import Settings +from app.core.timezone import BEIJING_TZ, to_beijing_naive from app.schemas.agent import SignalItem logger = logging.getLogger(__name__) class NewsSearchClient: - """通过公开新闻搜索接口采集动态AI新闻候选。""" + """通过公开技术新闻检索接口采集动态AI新闻候选。""" - endpoint = "https://api.gdeltproject.org/api/v2/doc/doc" + endpoint = "https://hn.algolia.com/api/v1/search_by_date" def __init__(self, settings: Settings) -> None: self.settings = settings def collect_ai_news(self, run_date: date) -> list[SignalItem]: - # GDELT按日期窗口返回全球新闻候选,模型后续只从这些证据里挑选日报条目。 + # HN Algolia按时间倒序返回技术新闻候选,模型后续只从这些证据里挑选日报条目。 since = run_date - timedelta(days=self.settings.news_recent_days) - articles: dict[str, dict[str, Any]] = {} - payload = self._search(query=self._query(), since=since, until=run_date) - for article in payload.get("articles", []): - url = article.get("url") - if isinstance(url, str) and url.startswith("http"): - articles[url] = article + stories: dict[str, dict[str, Any]] = {} + for query in self._queries(): + payload = self._search(query=query, since=since, until=run_date) + for story in payload.get("hits", []): + url = self._story_url(story) + object_id = story.get("objectID") + key = str(object_id or url) + if isinstance(url, str) and url.startswith("http"): + stories[key] = story - candidates = [self._article_to_signal(article) for article in articles.values()] + candidates = [self._story_to_signal(story) for story in stories.values()] candidates = [candidate for candidate in candidates if candidate is not None] candidates.sort(key=lambda item: item.published_at or datetime.min, reverse=True) logger.info("news_candidates_collected days=%s items=%s", self.settings.news_recent_days, len(candidates)) - return candidates[: self.settings.news_search_max_records] + return candidates[: self.settings.news_max_items] - def _query(self) -> str: - # GDELT限制请求频率,单次组合查询覆盖模型、Agent、开源和融资四类情报。 - return ( - '("AI agent" OR "agent framework" OR "LLM agent" OR ' - '"large language model" OR LLM OR "AI model" OR ' - '"open source AI" OR "open-source AI" OR "AI startup funding")' - ) + def _queries(self) -> list[str]: + # 查询词覆盖模型、Agent、开源AI和融资产品动态,避免单一关键词漏掉日报候选。 + return [ + "AI OR LLM OR agent", + "large language model", + "OpenAI Anthropic", + "AI startup funding", + ] def _search(self, query: str, since: date, until: date) -> dict[str, Any]: - # ArtList模式返回标题、URL、域名和发现时间,正好满足候选证据采集。 + # search_by_date接口稳定返回最新故事,numericFilters把候选限制在本次日报窗口内。 + start_at = int(datetime.combine(since, time.min, tzinfo=BEIJING_TZ).timestamp()) + end_at = int(datetime.combine(until, time.max, tzinfo=BEIJING_TZ).timestamp()) params = { "query": query, - "mode": "ArtList", - "format": "json", - "maxrecords": str(self.settings.news_search_max_records), - "sort": "HybridRel", - "startdatetime": since.strftime("%Y%m%d000000"), - "enddatetime": until.strftime("%Y%m%d235959"), + "tags": "story", + "hitsPerPage": str(self.settings.news_search_max_records), + "numericFilters": f"created_at_i>={start_at},created_at_i<={end_at}", } url = f"{self.endpoint}?{urlencode(params)}" request = Request(url, headers={"User-Agent": "SignalScout"}) try: - with urlopen(request, timeout=30) as response: + with urlopen(request, timeout=20) as response: body = response.read().decode("utf-8") except HTTPError as exc: message = exc.read().decode("utf-8", errors="replace") @@ -69,31 +73,42 @@ class NewsSearchClient: raise RuntimeError(f"News search failed: {exc.reason}") from exc return json.loads(body) - def _article_to_signal(self, article: dict[str, Any]) -> Optional[SignalItem]: - # 新闻候选先保留标题、链接和来源,重要性与摘要由模型最终确定。 - title = article.get("title") - url = article.get("url") + def _story_to_signal(self, story: dict[str, Any]) -> Optional[SignalItem]: + # HN故事先保留标题、链接和互动指标,重要性与摘要由模型最终确定。 + title = story.get("title") + url = self._story_url(story) if not isinstance(title, str) or not isinstance(url, str): return None - domain = str(article.get("domain") or "News") - source_country = str(article.get("sourcecountry") or "") - published_at = self._parse_gdelt_date(article.get("seendate")) + author = str(story.get("author") or "unknown") + points = int(story.get("points") or 0) + comments = int(story.get("num_comments") or 0) + published_at = self._parse_hn_date(story.get("created_at")) return SignalItem( topic="AI 新闻候选", title=title[:300], - summary=f"来自 {domain} 的新闻候选,GDELT发现时间为 {article.get('seendate') or '未知'}。", + summary=f"Hacker News 最新技术讨论,作者 {author},约 {points} points、{comments} 条评论。", source_url=url, - source_name=domain, + source_name="Hacker News", published_at=published_at, importance=3, - entities=[value for value in [domain, source_country] if value], + entities=["Hacker News", author], ) - def _parse_gdelt_date(self, value: Any) -> Optional[datetime]: - # GDELT使用类似20260708T120000Z的时间格式。 + def _story_url(self, story: dict[str, Any]) -> Optional[str]: + # 外链优先;没有外链的Ask/Show HN故事使用HN讨论页作为证据链接。 + url = story.get("url") or story.get("story_url") + if isinstance(url, str) and url.startswith("http"): + return url + object_id = story.get("objectID") + if object_id: + return f"https://news.ycombinator.com/item?id={object_id}" + return None + + def _parse_hn_date(self, value: Any) -> Optional[datetime]: + # HN Algolia返回UTC ISO时间,入库前先换算为北京时间。 if not isinstance(value, str): return None try: - return datetime.strptime(value, "%Y%m%dT%H%M%SZ") + return to_beijing_naive(datetime.fromisoformat(value.replace("Z", "+00:00"))) except ValueError: return None diff --git a/app/ui/home.py b/app/ui/home.py index 73a5fc6..a6f52f3 100644 --- a/app/ui/home.py +++ b/app/ui/home.py @@ -454,9 +454,12 @@ HOME_PAGE_HTML = """ function formatDate(value) { if (!value) return "未标注时间"; - const date = new Date(value); + const text = String(value); + const normalized = /(?:Z|[+-]\\d{2}:\\d{2})$/.test(text) ? text : `${text}+08:00`; + const date = new Date(normalized); if (Number.isNaN(date.getTime())) return "未标注时间"; return date.toLocaleString("zh-CN", { + timeZone: "Asia/Shanghai", month: "2-digit", day: "2-digit", hour: "2-digit", diff --git a/migrations/versions/202607081530_store_business_time.py b/migrations/versions/202607081530_store_business_time.py new file mode 100644 index 0000000..96ba084 --- /dev/null +++ b/migrations/versions/202607081530_store_business_time.py @@ -0,0 +1,45 @@ +"""store business time in beijing timezone + +Revision ID: 202607081530 +Revises: 202607080900 +Create Date: 2026-07-08 15:30:00 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "202607081530" +down_revision: Union[str, None] = "202607080900" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 历史数据由UTC换算为北京时间,后续新数据由业务层直接写入北京时间。 + op.execute("UPDATE agent_runs SET started_at = DATE_ADD(started_at, INTERVAL 8 HOUR)") + op.execute( + "UPDATE agent_runs SET finished_at = DATE_ADD(finished_at, INTERVAL 8 HOUR) " + "WHERE finished_at IS NOT NULL" + ) + op.execute("UPDATE reports SET created_at = DATE_ADD(created_at, INTERVAL 8 HOUR)") + op.execute("UPDATE signals SET created_at = DATE_ADD(created_at, INTERVAL 8 HOUR)") + op.execute( + "UPDATE signals SET published_at = DATE_ADD(published_at, INTERVAL 8 HOUR) " + "WHERE published_at IS NOT NULL" + ) + + +def downgrade() -> None: + # 回滚时恢复迁移前的UTC记录值,便于迁移链保持可逆。 + op.execute("UPDATE agent_runs SET started_at = DATE_SUB(started_at, INTERVAL 8 HOUR)") + op.execute( + "UPDATE agent_runs SET finished_at = DATE_SUB(finished_at, INTERVAL 8 HOUR) " + "WHERE finished_at IS NOT NULL" + ) + op.execute("UPDATE reports SET created_at = DATE_SUB(created_at, INTERVAL 8 HOUR)") + op.execute("UPDATE signals SET created_at = DATE_SUB(created_at, INTERVAL 8 HOUR)") + op.execute( + "UPDATE signals SET published_at = DATE_SUB(published_at, INTERVAL 8 HOUR) " + "WHERE published_at IS NOT NULL" + ) diff --git a/tests/test_github_client.py b/tests/test_github_client.py index ce23fbd..147db9d 100644 --- a/tests/test_github_client.py +++ b/tests/test_github_client.py @@ -23,3 +23,5 @@ def test_github_client_maps_repository_to_signal() -> None: assert signal.source_url == "https://github.com/example/agent-kit" assert "2400 stars" in signal.summary assert "2026-05-01" in signal.summary + assert signal.published_at is not None + assert signal.published_at.hour == 17 diff --git a/tests/test_news_search_client.py b/tests/test_news_search_client.py index 92eeb6f..a172a07 100644 --- a/tests/test_news_search_client.py +++ b/tests/test_news_search_client.py @@ -5,18 +5,22 @@ from app.integrations.news_search_client import NewsSearchClient def test_news_search_client_maps_article_to_candidate() -> None: # 新闻搜索结果会先转为候选信号,再交给模型筛选成最终情报。 client = NewsSearchClient(Settings()) - signal = client._article_to_signal( + signal = client._story_to_signal( { - "title": "AI agent platform launches", + "title": "Show HN: AI agent platform launches", "url": "https://example.com/ai-agent-platform", - "domain": "example.com", - "sourcecountry": "US", - "seendate": "20260708T120000Z", + "author": "builder", + "points": 128, + "num_comments": 24, + "created_at": "2026-07-08T12:00:00Z", + "objectID": "123", } ) assert signal is not None assert signal.topic == "AI 新闻候选" - assert signal.source_name == "example.com" + assert signal.source_name == "Hacker News" assert signal.published_at is not None assert signal.published_at.year == 2026 + assert signal.published_at.hour == 20 + assert "128 points" in signal.summary diff --git a/tests/test_timezone.py b/tests/test_timezone.py new file mode 100644 index 0000000..39c2754 --- /dev/null +++ b/tests/test_timezone.py @@ -0,0 +1,23 @@ +from datetime import datetime, timezone + +from app.core.timezone import beijing_now, beijing_today, to_beijing_naive + + +def test_beijing_now_returns_naive_business_time() -> None: + # 数据库存储无时区时间,业务层写入前统一生成北京时间。 + current = beijing_now() + + assert current.tzinfo is None + + +def test_beijing_today_returns_date() -> None: + # 日报业务日期只需要日期部分,由北京时间统一决定。 + assert beijing_today().isoformat() + + +def test_to_beijing_naive_converts_utc_time() -> None: + # 外部UTC时间进入数据库前要先换算成北京时间,避免前端显示少8小时。 + current = to_beijing_naive(datetime(2026, 7, 8, 14, 46, tzinfo=timezone.utc)) + + assert current.tzinfo is None + assert current.hour == 22