main
  1#!/venv/bin/python
  2# -*- coding: utf-8 -*-
  3import json
  4import tempfile
  5from collections import defaultdict
  6from contextlib import suppress
  7from datetime import datetime, timedelta
  8from pathlib import Path
  9from zoneinfo import ZoneInfo
 10
 11import feedparser
 12from feedgen.feed import FeedGenerator
 13from glom import glom
 14from loguru import logger
 15from pyrogram.types import Chat, Message
 16from pyrogram.types.messages_and_media.message import Str
 17
 18from ai.main import ai_text_generation
 19from config import DB, PREFIX, TZ
 20from database.d1 import query_d1
 21from database.r2 import get_cf_r2, head_cf_r2, set_cf_r2
 22from utils import convert_html, nowdt, rand_number
 23
 24DAILY_KEY = "AI-NEWS/daily.xml"
 25WEEKLY_KEY = "AI-NEWS/weekly.xml"
 26
 27
 28JSON_SCHEMA = {
 29    "title": "List of News Summary",
 30    "type": "array",
 31    "items": {
 32        "type": "object",
 33        "title": "News",
 34        "properties": {
 35            "category": {"description": "News category", "enum": ["Model", "Tech", "Finance", "Policy", "Application", "Industry", "Others"], "title": "Category", "type": "string"},
 36            "abstract": {"description": "A concise summary (50-100 words) in Chinese. Do NOT include URL strings here. Focus on facts and impact.", "title": "Abstract", "type": "string"},
 37            "title": {"description": "A concise, objective Chinese title (max 20 chars). Remove clickbait words.", "title": "Title", "type": "string"},
 38            "urls": {"description": "Select 1-2 most authoritative source URLs.", "items": {"type": "string"}, "title": "Urls", "type": "array"},
 39        },
 40        "required": ["category", "title", "abstract", "urls"],
 41        "additionalProperties": False,
 42    },
 43}
 44
 45
 46async def daily_ainews(num_days: int = 7):
 47    """Get daily AI summary (num_days)."""
 48    parsed = await parse_finished(DAILY_KEY)
 49    if not parsed:
 50        logger.warning("No Daily AI news parsed.")
 51        return
 52    now = nowdt(TZ)
 53    begin = now - timedelta(days=num_days)
 54    items = {}
 55    for i in range(num_days):
 56        day = begin + timedelta(days=i)
 57        day_str = day.strftime("%Y-%m-%d")
 58        if day_str in parsed:
 59            continue
 60        news_summary = await get_summary(day_str, day_str)
 61        if not news_summary:
 62            continue
 63        await set_cf_r2(key=f"AI-NEWS/daily/{day_str}-summary.json", data=news_summary)  # save news summary
 64        # publish the summary
 65        title = f"AI日报 | {day_str}"
 66        html = convert_news_summary_to_html(news_summary)
 67        full_html = f'<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{title}</title><link rel="stylesheet" href="https://cdn.jsdmirror.cn/npm/water.css@2/out/water.css" integrity="sha512-GW7j11RXZmdio87hQsKNjomKEy/DwDjh6J2Z1JnI5Z4FNP791QfZP7Iut25vA+L+YJLZipI2BZhEpkvBkfr8cw==" crossorigin="anonymous"></head><body><h1>{title}</h1>{html}</body></html>'
 68        html_key = f"AI-NEWS/daily/{day_str}.html"
 69        await set_cf_r2(key=html_key, data=full_html, mime_type="text/html")
 70        items[day_str] = {"title": title, "summary": html, "url": f"{DB.CF_R2_PUBLIC_URL}/{html_key}"}
 71    if not items:
 72        return
 73    items |= parsed
 74    fg = FeedGenerator()
 75    fg.link(href=f"{DB.CF_R2_PUBLIC_URL}/{DAILY_KEY}", rel="self", type="application/rss+xml")
 76    fg.title("AI News")
 77    fg.language("zh-CN")
 78    fg.category(term="AI")
 79    fg.copyright("DNKT AI News")
 80    fg.description("DNKT AI News")
 81    fg.ttl(60)
 82    for day_str, item in sorted(items.items()):
 83        entry = fg.add_entry()
 84        entry.title(item["title"])
 85        entry.link(href=item["url"])
 86        entry.guid(item["url"], permalink=True)
 87        pub_dt = datetime.strptime(day_str, "%Y-%m-%d").replace(hour=23, minute=59, second=59, tzinfo=ZoneInfo(TZ))
 88        entry.published(pub_dt)
 89        entry.content(item["summary"], type="CDATA")
 90
 91    with tempfile.NamedTemporaryFile("w", suffix=".xml", delete=False) as tempf:
 92        fg.rss_file(tempf.name, pretty=True)
 93        # add rss beauty
 94        xml_str = Path(tempf.name).read_text()
 95        xml_str = xml_str.replace("<?xml version='1.0' encoding='UTF-8'?>", "<?xml version='1.0' encoding='UTF-8'?>\n<?xml-stylesheet type='text/xsl' href='/rss.xsl'?>")
 96        await set_cf_r2(DAILY_KEY, xml_str, mime_type="application/xml", silent=True)
 97
 98
 99async def get_summary(start: str, end: str) -> list[dict]:
100    """Get AI summary.
101
102    Args:
103        start (str): The start time in YYYY-MM-DD.
104        end (str): The end time in YYYY-MM-DD.
105
106    Returns:
107        list[dict]: list of news summary
108    """
109    start_dt = datetime.strptime(start, "%Y-%m-%d").replace(hour=0, minute=0, second=0, tzinfo=ZoneInfo(TZ))
110    end_dt = datetime.strptime(end, "%Y-%m-%d").replace(hour=23, minute=59, second=59, tzinfo=ZoneInfo(TZ))
111    start_ts = int(start_dt.timestamp())
112    end_ts = int(end_dt.timestamp())
113    d1 = await query_d1(sql=f"SELECT timestamp,title,url,summary FROM readhub WHERE timestamp >= {start_ts} AND timestamp <= {end_ts} ORDER BY timestamp ASC", db_name="dnkt", silent=True)
114    items = glom(d1, "result.0.results", default=[])  # From old to new
115    if not items:
116        return []
117    prompt = generate_ai_prompt(items)
118    # save sources to R2
119    if start == end:  # daily
120        await set_cf_r2(f"AI-NEWS/daily/{start}.json", items, silent=True)
121    ai_msg = Message(  # Construct a message for AI
122        id=rand_number(),
123        chat=Chat(id=rand_number()),
124        text=Str(f"{PREFIX.AI_TEXT_GENERATION} @ai-news {prompt}"),
125    )
126
127    ai_res = await ai_text_generation(
128        "fake-client",  # type: ignore
129        ai_msg,
130        openai_responses_config={
131            "instructions": daily_system_prompt(start),
132            "text": {
133                "format": {
134                    "type": "json_schema",
135                    "name": "NewsSummary",
136                    "strict": True,
137                    "description": "A list of news summary",
138                    "schema": JSON_SCHEMA,
139                }
140            },
141        },
142        gemini_generate_content_config={
143            "system_instruction": daily_system_prompt(start),
144            "responseMimeType": "application/json",
145            "responseJsonSchema": JSON_SCHEMA,
146        },
147        silent=True,
148    )
149    if not ai_res.get("texts", ""):
150        return []
151    return json.loads(ai_res.get("texts", ""))
152
153
154def convert_news_summary_to_html(news_summary: list[dict]) -> str:
155    """Convert news summary to html."""
156    emoji = {"Model": "🤖", "Tech": "🛠️", "Finance": "💰", "Policy": "📜", "Application": "💡", "Industry": "🏢", "Others": ""}
157    category_order = ["Model", "Tech", "Application", "Industry", "Finance", "Policy", "Others"]
158    category_map = {
159        "Model": "模型 (Models)",
160        "Tech": "技术 (Technologies)",
161        "Application": "应用 (Applications)",
162        "Industry": "行业动态 (Industry)",
163        "Finance": "投融资 (Finance)",
164        "Policy": "政策 (Policy)",
165        "Others": "其他 (Others)",
166    }
167
168    # Group by category
169    grouped = defaultdict(list)
170    for item in news_summary:
171        category = item.get("category", "Others")
172        if category not in category_order:
173            continue
174        grouped[category].append(item)
175
176    html = ""
177    for category in category_order:
178        items = grouped[category]
179        if not items:
180            continue
181        html += f"<h2>{emoji[category]} {category_map[category]}</h2>"
182        for idx, item in enumerate(items):
183            title = item["title"]
184            summary = item["abstract"]
185            if len(item["urls"]) == 1:
186                html += f'<p><strong>{idx + 1}. <a href="{item["urls"][0]}">{title}</a></strong><br>{summary}</p>'
187            else:
188                urls = ""
189                for uidx, url in enumerate(item["urls"]):
190                    urls += f'<a href="{url}">[link-{uidx + 1}]</a>,'
191                urls = urls.rstrip(",")
192                html += f"<p><strong>{idx + 1}. {title}</strong> {urls}<br>{summary}</p>"
193    return html
194
195
196async def parse_finished(key: str = DAILY_KEY) -> dict:
197    parsed = {}
198    with suppress(Exception):
199        feed_url = f"{DB.CF_R2_PUBLIC_URL.rstrip('/')}/{key}"
200        feed = feedparser.parse(feed_url)
201        for item in glom(feed, "entries", default=[]):
202            title = item["title"]
203            summary = item["summary"]
204            url = item["link"]
205            date = title[-10:]  # YYYY-MM-DD
206            parsed[date] = {"title": title, "summary": summary, "url": url}
207    return parsed
208
209
210def generate_ai_prompt(items: list[dict]) -> str:
211    """Generate AI prompt in json format."""
212    prompt = []
213    for idx, item in enumerate(items):  # noqa: B007
214        title = item["title"]
215        summary = item["summary"]
216        url = item["url"].removeprefix("?f=rss")
217        prompt.append({"title": title, "summary": summary, "url": url})
218    return json.dumps(prompt, ensure_ascii=False)
219
220
221def daily_system_prompt(day: str) -> str:
222    prompt = f"""# Role
223你是一位资深的人工智能行业分析师和主编。你具备敏锐的新闻嗅觉,擅长从海量、碎片化的源数据中识别关键信息,并将其转化为结构化、高价值的行业简报。
224
225# Context
226当前日期:{day}
227输入数据:一份包含本日 AI 相关新闻的 JSON 列表(包含标题 title、摘要 summary、链接 url)。
228输出目标:符合 JSON Schema 定义的清洗后的新闻列表。
229
230# Workflow & Guidelines (必须严格执行)
231
232## 1. 深度清洗与去重 (核心任务)
233   - **语义聚合**:不要仅仅比对标题。如果多条新闻报道了同一个核心事件(例如 "OpenAI 发布 Sora""Sora 正式官宣"),必须将其合并为一个条目。
234   - **去除噪音**:剔除毫无实质内容的标题党、重复的营销软文、极其边缘的小道消息。
235   - **选取最佳来源**:在合并时,保留 1-2 个最具信息量的 URL 放入 `urls` 字段。
236
237## 2. 智能分类 (Category)
238   请根据新闻核心内容,严格按以下标准分类:
239   - **Model**: 大模型发布、权重开源、算法更新 (如 GPT-5, Llama 3)。
240   - **Tech**: 具体的 AI 技术突破、学术论文、RAG/Agent 等技术架构 (非具体模型)。
241   - **Application**: AI 产品落地、新功能上线、具体场景应用 (如 Copilot 更新)。
242   - **Finance**: 融资、收购、上市、财报、股价大幅波动。
243   - **Industry**: 行业大事件、人事变动、监管与法律诉讼、算力芯片 (Nvidia/AMD)。
244   - **Policy**: 政府法规、AI 安全倡议、白皮书。
245   - **Others**: 无法归类的内容。
246
247## 3. 摘要撰写规范 (Abstract)
248   - **禁止包含链接**:摘要文本中**绝对不要**包含 http 链接,链接仅需放入 JSON 的 `urls` 列表即可。
249   - **客观陈述**:使用第三方新闻语调。例如:"OpenAI 宣布推出..." 而不是 "我们很高兴地宣布..."
250   - **信息密度**:50-100 字。涵盖 "Who (谁) + What (做了什么) + Impact (有什么关键影响/参数)"
251   - **中文输出**:即使输入是英文,摘要和标题也必须是流畅的中文。
252
253## 4. 标题重写 (Title)
254   - 必须简练(20字以内)。
255   - 去除 "突发""重磅" 等修饰词,直接陈述事实。
256
257请处理输入数据,并生成符合 Schema 的 JSON 输出。
258"""  # noqa: RUF001
259    return prompt.strip()
260
261
262def get_last_week() -> tuple[datetime, datetime]:
263    """Get last monday and sunday's date."""
264    today = nowdt(TZ)
265    this_monday = today - timedelta(days=today.weekday())
266    previous_monday = this_monday - timedelta(days=7)
267    previous_sunday = this_monday - timedelta(days=1)
268    return previous_monday.replace(hour=0, minute=0, second=0, tzinfo=ZoneInfo(TZ)), previous_sunday.replace(hour=23, minute=59, second=59, tzinfo=ZoneInfo(TZ))
269
270
271async def weekly_ainews():
272    last_monday, last_sunday = get_last_week()
273    html_key = f"AI-NEWS/weekly/{last_monday:%Y-%m-%d}~{last_sunday:%Y-%m-%d}.html"
274    if await head_cf_r2(html_key):
275        return
276
277    parsed = await parse_finished(WEEKLY_KEY)
278    # if not parsed:
279    #     logger.warning("No Weekly AI news parsed.")
280    #     return
281
282    # gather news
283    news = []
284    for i in range(7):
285        day = last_monday + timedelta(days=i)
286        list_news: list[dict] = await get_cf_r2(key=f"AI-NEWS/daily/{day:%Y-%m-%d}-summary.json", silent=True)  # ty:ignore[invalid-assignment]
287        news.extend(list_news)
288    for x in news:
289        x.pop("category", None)
290
291    ai_msg = Message(  # Construct a message for AI
292        id=rand_number(),
293        chat=Chat(id=rand_number()),
294        text=Str(f"{PREFIX.AI_TEXT_GENERATION} @doubao {json.dumps(news, ensure_ascii=False)}"),
295    )
296    ai_res = await ai_text_generation(
297        "fake-client",  # type: ignore
298        ai_msg,
299        openai_responses_config={"instructions": weekly_system_prompt()},
300        gemini_generate_content_config={"system_instruction": weekly_system_prompt()},
301        silent=True,
302    )
303    weekly_report = ai_res.get("texts", "")
304    if not weekly_report:
305        return
306    title = f"AI周报 | {last_monday:%Y-%m-%d}~{last_sunday:%Y-%m-%d}"
307    html = convert_html(weekly_report)
308    full_html = f'<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{title}</title><link rel="stylesheet" href="https://cdn.jsdmirror.cn/npm/water.css@2/out/water.css" integrity="sha512-GW7j11RXZmdio87hQsKNjomKEy/DwDjh6J2Z1JnI5Z4FNP791QfZP7Iut25vA+L+YJLZipI2BZhEpkvBkfr8cw==" crossorigin="anonymous"></head><body><h1>{title}</h1>{html}</body></html>'
309    await set_cf_r2(key=html_key, data=full_html, mime_type="text/html")
310
311    parsed[f"{last_sunday:%Y-%m-%d}"] = {"title": f"AI周报 | {last_monday:%Y-%m-%d}~{last_sunday:%Y-%m-%d}", "summary": html, "url": f"{DB.CF_R2_PUBLIC_URL}/{html_key}"}
312    fg = FeedGenerator()
313    fg.link(href=f"{DB.CF_R2_PUBLIC_URL}/{DAILY_KEY}", rel="self", type="application/rss+xml")
314    fg.title("AI周报")
315    fg.language("zh-CN")
316    fg.category(term="AI")
317    fg.copyright("DNKT AI News")
318    fg.description("DNKT AI News 周报")
319    fg.ttl(480)
320    for sunday, item in sorted(parsed.items()):
321        entry = fg.add_entry()
322        entry.title(item["title"])
323        entry.link(href=item["url"])
324        entry.guid(item["url"], permalink=True)
325        pub_dt = datetime.strptime(sunday, "%Y-%m-%d").replace(hour=23, minute=59, second=59, tzinfo=ZoneInfo(TZ))
326        entry.published(pub_dt)
327        entry.content(item["summary"], type="CDATA")
328
329    with tempfile.NamedTemporaryFile("w", suffix=".xml", delete=False) as tempf:
330        fg.rss_file(tempf.name, pretty=True)
331        # add rss beauty
332        xml_str = Path(tempf.name).read_text()
333        xml_str = xml_str.replace("<?xml version='1.0' encoding='UTF-8'?>", "<?xml version='1.0' encoding='UTF-8'?>\n<?xml-stylesheet type='text/xsl' href='/rss.xsl'?>")
334        await set_cf_r2(WEEKLY_KEY, xml_str, mime_type="application/xml", silent=True)
335
336
337def weekly_system_prompt() -> str:
338    prompt = """# Role Definition
339你是一名为顶级汽车软件公司服务的**首席AI技术战略官**,精通 **QNX、Linux (AGL)、Android** 混合架构,并对**智能座舱 HMI(仪表/中控)**开发有深刻理解。你的核心能力结合了对 AI 前沿技术(AGI、LLM、Multimodal)的敏锐嗅觉。
340
341# Context & Goal
342用户是服务于丰田系汽车的软件开发工程师,主要负责**仪表盘 (Instrument Cluster)** 和 **中控屏 (IVI)** 的软件开发。
343你需要根据用户提供的 JSON 数据(包含 title, summary, urls),撰写一份具有深度的《AI行业周报》。
344你的目标不是简单的“新闻搬运”,而是“情报分析”。你需要帮助用户回答两个核心问题:
3451. 本周 AI 行业发生了什么可能会改变未来的大事?
3462. 这些技术如何具体落地到我们的汽车软件代码、架构或用户体验中?
347
348# Capabilities & Constraints
349由于你具备**联网搜索能力**,请严格遵守以下工作流:
350
351## Phase 1: Data Ingestion & Enrichment (数据摄入与增强)
3521. **去重与聚类**:阅读输入的 JSON 数据。识别报道同一事件的多条新闻(例如多家媒体报道同一个模型发布),将其聚合为一个条目。
3532. **主动搜索 (关键步骤)**:
354   - 如果 JSON 中的 `summary` 信息量不足,或者该新闻通过标题判断极其重要(Tier 1级别),**请务必使用搜索工具搜索该新闻的最新深度解读或技术白皮书**。
355   - 不要仅依赖提供的 JSON,要确保你的分析是基于最准确、最全面的技术细节(例如:模型参数量、推理延迟数据、上下文长度等)。
356
357## Phase 2: Strategic Filtering (战略筛选)
358请将新闻分为以下三类,并按此优先级排序:
359
360- **Tier 1: 行业范式转移 (Paradigm Shifts)**
361  - *标准*:基座模型的重大迭代(如 GPT-5, Gemini 1.5 Ultra)、颠覆性的新架构(如 SSM, Mamba 变体)、关键开源项目、或可能重塑行业的政策/商业动态。
362  - *处理*:即使与汽车无关,也**必须收录**。这是周报的核心价值。
363
364- **Tier 2: 车载软件强相关 (Automotive Relevant)**
365  - *标准*:直接涉及智能座舱、语音交互、端侧推理 (On-device AI)、RAG 在边缘端的应用、AI 辅助编程 (Copilot for Devs)、实时渲染等。
366  - *处理*:重点分析落地可行性。
367
368- **Tier 3: 噪音过滤 (Drop)**
369  - *标准*:单纯的股价波动、无技术细节的营销通稿、重复的低质量内容。
370  - *处理*:直接丢弃。
371
372## Phase 3: Analysis & Writing (分析与撰写)
373在撰写每条新闻时,必须包含 **[深度技术解析]** 和 **[车机软件启示]**。
374
375**关于 [车机软件启示] 的特殊指令:**
376- **禁止**说空话(如“这将提升用户体验”)。
377- **必须**联系具体的软件开发场景。请思考以下维度:
378  - **性能与架构**:是否涉及 NPU 算力占用?模型量化后是否能在高通/英伟达车规芯片上跑通?
379  - **交互创新**:是否支持多模态(手势+语音)?能否用于生成动态 UI?
380  - **开发效率**:该工具能否帮助车企软件团队自动生成单元测试或重构代码?
381  - **隐私与安全**:端侧处理对用户隐私意味着什么?
382
383# Output Format (Markdown)
384
385## 🚨 行业头条 (Headline Events)
386*(针对 Tier 1 新闻,深入剖析)*
387
388### [1. 新闻标题]
389> **核心事实**:[融合了 JSON 信息和你联网搜索补充的关键技术参数]
390> **行业震级**:⭐⭐⭐⭐⭐ (简述为什么这是行业拐点)
391> **对车机软件的降维打击**:[发散思维,例如:*“虽然这是云端大模型,但其蒸馏版本可能在明年进入车机,改变现有的语音助手架构...”*]
392
393---
394
395## 🚘 智能座舱与工程应用 (Cockpit & Engineering)
396*(针对 Tier 2 新闻,侧重落地)*
397
398### [2. 新闻标题]
399- **技术摘要**:[简述]
400- **落地分析 (Actionable Insight)**:
401  - *场景*:[具体场景,如:导航搜索、儿童模式生成、DMS疲劳监测]
402  - *挑战*:[提及延迟、功耗或内存限制]
403
404---
405
406## 🔍 速览 (Quick Bites)
407*(值得注意但篇幅较短的更新)*
408- **[标题]**:一句话核心观点。🔗[Link]
409
410---
411
412## 📝 本周总结 (Weekly Synthesis)
413[用一段话总结本周趋势。直接对作为 AI 工程师的用户喊话,给出下周关注的技术栈建议。]
414"""  # noqa: RUF001
415    return prompt.strip()