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 convert2html, 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} @general {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        openai_append_tool_results=False,
148        gemini_append_grounding=False,
149        anthropic_append_citation=False,
150        silent=True,
151    )
152    if not ai_res.get("texts", ""):
153        return []
154    return json.loads(ai_res.get("texts", ""))
155
156
157def convert_news_summary_to_html(news_summary: list[dict]) -> str:
158    """Convert news summary to html."""
159    emoji = {"Model": "🤖", "Tech": "🛠️", "Finance": "💰", "Policy": "📜", "Application": "💡", "Industry": "🏢", "Others": ""}
160    category_order = ["Model", "Tech", "Application", "Industry", "Finance", "Policy", "Others"]
161    category_map = {
162        "Model": "模型 (Models)",
163        "Tech": "技术 (Technologies)",
164        "Application": "应用 (Applications)",
165        "Industry": "行业动态 (Industry)",
166        "Finance": "投融资 (Finance)",
167        "Policy": "政策 (Policy)",
168        "Others": "其他 (Others)",
169    }
170
171    # Group by category
172    grouped = defaultdict(list)
173    for item in news_summary:
174        category = item.get("category", "Others")
175        if category not in category_order:
176            continue
177        grouped[category].append(item)
178
179    html = ""
180    for category in category_order:
181        items = grouped[category]
182        if not items:
183            continue
184        html += f"<h2>{emoji[category]} {category_map[category]}</h2>"
185        for idx, item in enumerate(items):
186            title = item["title"]
187            summary = item["abstract"]
188            if len(item["urls"]) == 1:
189                html += f'<p><strong>{idx + 1}. <a href="{item["urls"][0]}">{title}</a></strong><br>{summary}</p>'
190            else:
191                urls = ""
192                for uidx, url in enumerate(item["urls"]):
193                    urls += f'<a href="{url}">[link-{uidx + 1}]</a>,'
194                urls = urls.rstrip(",")
195                html += f"<p><strong>{idx + 1}. {title}</strong> {urls}<br>{summary}</p>"
196    return html
197
198
199async def parse_finished(key: str = DAILY_KEY) -> dict:
200    parsed = {}
201    with suppress(Exception):
202        feed_url = f"{DB.CF_R2_PUBLIC_URL.rstrip('/')}/{key}"
203        feed = feedparser.parse(feed_url)
204        for item in glom(feed, "entries", default=[]):
205            title = item["title"]
206            summary = item["summary"]
207            url = item["link"]
208            date = title[-10:]  # YYYY-MM-DD
209            parsed[date] = {"title": title, "summary": summary, "url": url}
210    return parsed
211
212
213def generate_ai_prompt(items: list[dict]) -> str:
214    """Generate AI prompt in json format."""
215    prompt = []
216    for idx, item in enumerate(items):  # noqa: B007
217        title = item["title"]
218        summary = item["summary"]
219        url = item["url"].removeprefix("?f=rss")
220        prompt.append({"title": title, "summary": summary, "url": url})
221    return json.dumps(prompt, ensure_ascii=False)
222
223
224def daily_system_prompt(day: str) -> str:
225    prompt = f"""# Role
226你是一位资深的人工智能行业分析师和主编。你具备敏锐的新闻嗅觉,擅长从海量、碎片化的源数据中识别关键信息,并将其转化为结构化、高价值的行业简报。
227
228# Context
229当前日期:{day}
230输入数据:一份包含本日 AI 相关新闻的 JSON 列表(包含标题 title、摘要 summary、链接 url)。
231输出目标:符合 JSON Schema 定义的清洗后的新闻列表。
232
233# Workflow & Guidelines (必须严格执行)
234
235## 1. 深度清洗与去重 (核心任务)
236   - **语义聚合**:不要仅仅比对标题。如果多条新闻报道了同一个核心事件(例如 "OpenAI 发布 Sora""Sora 正式官宣"),必须将其合并为一个条目。
237   - **去除噪音**:剔除毫无实质内容的标题党、重复的营销软文、极其边缘的小道消息。
238   - **选取最佳来源**:在合并时,保留 1-2 个最具信息量的 URL 放入 `urls` 字段。
239
240## 2. 智能分类 (Category)
241   请根据新闻核心内容,严格按以下标准分类:
242   - **Model**: 大模型发布、权重开源、算法更新 (如 GPT-5, Llama 3)。
243   - **Tech**: 具体的 AI 技术突破、学术论文、RAG/Agent 等技术架构 (非具体模型)。
244   - **Application**: AI 产品落地、新功能上线、具体场景应用 (如 Copilot 更新)。
245   - **Finance**: 融资、收购、上市、财报、股价大幅波动。
246   - **Industry**: 行业大事件、人事变动、监管与法律诉讼、算力芯片 (Nvidia/AMD)。
247   - **Policy**: 政府法规、AI 安全倡议、白皮书。
248   - **Others**: 无法归类的内容。
249
250## 3. 摘要撰写规范 (Abstract)
251   - **禁止包含链接**:摘要文本中**绝对不要**包含 http 链接,链接仅需放入 JSON 的 `urls` 列表即可。
252   - **客观陈述**:使用第三方新闻语调。例如:"OpenAI 宣布推出..." 而不是 "我们很高兴地宣布..."
253   - **信息密度**:50-100 字。涵盖 "Who (谁) + What (做了什么) + Impact (有什么关键影响/参数)"
254   - **中文输出**:即使输入是英文,摘要和标题也必须是流畅的中文。
255
256## 4. 标题重写 (Title)
257   - 必须简练(20字以内)。
258   - 去除 "突发""重磅" 等修饰词,直接陈述事实。
259
260## 5. 关注重点
261    - 仅保留你认为比较重要的事件,无需囊括所有新闻条目。
262    - 每个分类中最多保留 10 条新闻。
263
264请处理输入数据,并生成符合 Schema 的 JSON 输出。
265"""  # noqa: RUF001
266    return prompt.strip()
267
268
269def get_last_week() -> tuple[datetime, datetime]:
270    """Get last monday and sunday's date."""
271    today = nowdt(TZ)
272    this_monday = today - timedelta(days=today.weekday())
273    previous_monday = this_monday - timedelta(days=7)
274    previous_sunday = this_monday - timedelta(days=1)
275    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))
276
277
278async def weekly_ainews():
279    last_monday, last_sunday = get_last_week()
280    html_key = f"AI-NEWS/weekly/{last_monday:%Y-%m-%d}~{last_sunday:%Y-%m-%d}.html"
281    if await head_cf_r2(html_key):
282        return
283
284    parsed = await parse_finished(WEEKLY_KEY)
285    # if not parsed:
286    #     logger.warning("No Weekly AI news parsed.")
287    #     return
288
289    # gather news
290    news = []
291    for i in range(7):
292        day = last_monday + timedelta(days=i)
293        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]
294        news.extend(list_news)
295    for x in news:
296        x.pop("category", None)
297
298    ai_msg = Message(  # Construct a message for AI
299        id=rand_number(),
300        chat=Chat(id=rand_number()),
301        text=Str(f"{PREFIX.AI_TEXT_GENERATION} @doubao {json.dumps(news, ensure_ascii=False)}"),
302    )
303    ai_res = await ai_text_generation(
304        "fake-client",  # type: ignore
305        ai_msg,
306        openai_responses_config={"instructions": weekly_system_prompt()},
307        gemini_generate_content_config={"system_instruction": weekly_system_prompt()},
308        silent=True,
309    )
310    weekly_report = ai_res.get("texts", "")
311    if not weekly_report:
312        return
313    title = f"AI周报 | {last_monday:%Y-%m-%d}~{last_sunday:%Y-%m-%d}"
314    html = convert2html(weekly_report)
315    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>'
316    await set_cf_r2(key=html_key, data=full_html, mime_type="text/html")
317
318    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}"}
319    fg = FeedGenerator()
320    fg.link(href=f"{DB.CF_R2_PUBLIC_URL}/{DAILY_KEY}", rel="self", type="application/rss+xml")
321    fg.title("AI周报")
322    fg.language("zh-CN")
323    fg.category(term="AI")
324    fg.copyright("DNKT AI News")
325    fg.description("DNKT AI News 周报")
326    fg.ttl(480)
327    for sunday, item in sorted(parsed.items()):
328        entry = fg.add_entry()
329        entry.title(item["title"])
330        entry.link(href=item["url"])
331        entry.guid(item["url"], permalink=True)
332        pub_dt = datetime.strptime(sunday, "%Y-%m-%d").replace(hour=23, minute=59, second=59, tzinfo=ZoneInfo(TZ))
333        entry.published(pub_dt)
334        entry.content(item["summary"], type="CDATA")
335
336    with tempfile.NamedTemporaryFile("w", suffix=".xml", delete=False) as tempf:
337        fg.rss_file(tempf.name, pretty=True)
338        # add rss beauty
339        xml_str = Path(tempf.name).read_text()
340        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'?>")
341        await set_cf_r2(WEEKLY_KEY, xml_str, mime_type="application/xml", silent=True)
342
343
344def weekly_system_prompt() -> str:
345    prompt = """# Role Definition
346你是一名为顶级汽车软件公司服务的**首席AI技术战略官**,精通 **QNX、Linux (AGL)、Android** 混合架构,并对**智能座舱 HMI(仪表/中控)**开发有深刻理解。你的核心能力结合了对 AI 前沿技术(AGI、LLM、Multimodal)的敏锐嗅觉。
347
348# Context & Goal
349用户是服务于丰田系汽车的软件开发工程师,主要负责**仪表盘 (Instrument Cluster)** 和 **中控屏 (IVI)** 的软件开发。
350你需要根据用户提供的 JSON 数据(包含 title, summary, urls),撰写一份具有深度的《AI行业周报》。
351你的目标不是简单的“新闻搬运”,而是“情报分析”。你需要帮助用户回答两个核心问题:
3521. 本周 AI 行业发生了什么可能会改变未来的大事?
3532. 这些技术如何具体落地到我们的汽车软件代码、架构或用户体验中?
354
355# Capabilities & Constraints
356由于你具备**联网搜索能力**,请严格遵守以下工作流:
357
358## Phase 1: Data Ingestion & Enrichment (数据摄入与增强)
3591. **去重与聚类**:阅读输入的 JSON 数据。识别报道同一事件的多条新闻(例如多家媒体报道同一个模型发布),将其聚合为一个条目。
3602. **主动搜索 (关键步骤)**:
361   - 如果 JSON 中的 `summary` 信息量不足,或者该新闻通过标题判断极其重要(Tier 1级别),**请务必使用搜索工具搜索该新闻的最新深度解读或技术白皮书**。
362   - 不要仅依赖提供的 JSON,要确保你的分析是基于最准确、最全面的技术细节(例如:模型参数量、推理延迟数据、上下文长度等)。
363
364## Phase 2: Strategic Filtering (战略筛选)
365请将新闻分为以下三类,并按此优先级排序:
366
367- **Tier 1: 行业范式转移 (Paradigm Shifts)**
368  - *标准*:基座模型的重大迭代(如 GPT-5, Gemini 1.5 Ultra)、颠覆性的新架构(如 SSM, Mamba 变体)、关键开源项目、或可能重塑行业的政策/商业动态。
369  - *处理*:即使与汽车无关,也**必须收录**。这是周报的核心价值。
370
371- **Tier 2: 车载软件强相关 (Automotive Relevant)**
372  - *标准*:直接涉及智能座舱、语音交互、端侧推理 (On-device AI)、RAG 在边缘端的应用、AI 辅助编程 (Copilot for Devs)、实时渲染等。
373  - *处理*:重点分析落地可行性。
374
375- **Tier 3: 噪音过滤 (Drop)**
376  - *标准*:单纯的股价波动、无技术细节的营销通稿、重复的低质量内容。
377  - *处理*:直接丢弃。
378
379## Phase 3: Analysis & Writing (分析与撰写)
380在撰写每条新闻时,必须包含 **[深度技术解析]** 和 **[车机软件启示]**。
381
382**关于 [车机软件启示] 的特殊指令:**
383- **禁止**说空话(如“这将提升用户体验”)。
384- **必须**联系具体的软件开发场景。请思考以下维度:
385  - **性能与架构**:是否涉及 NPU 算力占用?模型量化后是否能在高通/英伟达车规芯片上跑通?
386  - **交互创新**:是否支持多模态(手势+语音)?能否用于生成动态 UI?
387  - **开发效率**:该工具能否帮助车企软件团队自动生成单元测试或重构代码?
388  - **隐私与安全**:端侧处理对用户隐私意味着什么?
389
390# Output Format (Markdown)
391
392## 🚨 行业头条 (Headline Events)
393*(针对 Tier 1 新闻,深入剖析)*
394
395### [1. 新闻标题]
396> **核心事实**:[融合了 JSON 信息和你联网搜索补充的关键技术参数]
397> **行业震级**:⭐⭐⭐⭐⭐ (简述为什么这是行业拐点)
398> **对车机软件的降维打击**:[发散思维,例如:*“虽然这是云端大模型,但其蒸馏版本可能在明年进入车机,改变现有的语音助手架构...”*]
399
400---
401
402## 🚘 智能座舱与工程应用 (Cockpit & Engineering)
403*(针对 Tier 2 新闻,侧重落地)*
404
405### [2. 新闻标题]
406- **技术摘要**:[简述]
407- **落地分析 (Actionable Insight)**:
408  - *场景*:[具体场景,如:导航搜索、儿童模式生成、DMS疲劳监测]
409  - *挑战*:[提及延迟、功耗或内存限制]
410
411---
412
413## 🔍 速览 (Quick Bites)
414*(值得注意但篇幅较短的更新)*
415- **[标题]**:一句话核心观点。🔗[Link]
416
417---
418
419## 📝 本周总结 (Weekly Synthesis)
420[用一段话总结本周趋势。直接对作为 AI 工程师的用户喊话,给出下周关注的技术栈建议。]
421"""  # noqa: RUF001
422    return prompt.strip()