Files
daily-robots/daily/news/research.py

325 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Cursor SDK + deep-research 工作流:采集 AI 时讯(方案 A内置 WebSearch"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from daily.config import OUTPUT_DIR, ROOT, env, env_int, wecom_ai_news_tech_limit
from daily.llm_client import cursor_agent_prompt, extract_json_object, has_cursor_configured
from daily.news.fetch import brief_news_summary, _normalize_link
from daily.news.pushed_links import filter_unpushed_items
logger = logging.getLogger(__name__)
_SKILL_DIR = ROOT / "skills" / "daily-ai-news-research"
_DEEP_RESEARCH_CANDIDATES = (
ROOT / "skills" / "deep-research" / "SKILL.md",
Path.home() / ".agents" / "skills" / "deep-research" / "SKILL.md",
Path.home() / ".cursor" / "skills" / "deep-research" / "SKILL.md",
)
def ai_news_mode() -> str:
return (env("DAILY_AI_NEWS_MODE") or "rss").strip().lower()
def is_research_mode() -> bool:
return ai_news_mode() == "research"
def research_hours() -> int:
return max(1, env_int("DAILY_AI_NEWS_HOURS", 24))
def research_limit() -> int:
return max(1, env_int("DAILY_WECOM_AI_NEWS", 10))
def research_json_path(date_str: str) -> Path:
return OUTPUT_DIR / f"{date_str}.ai-news-research.json"
def _save_research_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def _load_skill() -> str:
parts: list[str] = []
for path in _DEEP_RESEARCH_CANDIDATES:
if path.exists():
parts.append(path.read_text(encoding="utf-8").strip())
break
local = _SKILL_DIR / "SKILL.md"
if local.exists():
parts.append(local.read_text(encoding="utf-8").strip())
if not parts:
return "你是 AI 时讯调研员,只输出 JSON。"
return "\n\n---\n\n".join(parts)
def _guess_source_name(link: str, explicit: str) -> str:
name = (explicit or "").strip()
if name:
return name
host = urlparse(link).netloc.lower().removeprefix("www.")
mapping = {
"techcrunch.com": "TechCrunch",
"theverge.com": "The Verge",
"openai.com": "OpenAI",
"anthropic.com": "Anthropic",
"arxiv.org": "arXiv",
"qbitai.com": "量子位",
"36kr.com": "36氪",
"leiphone.com": "雷锋网",
}
for key, label in mapping.items():
if host.endswith(key) or key in host:
return label
return host.split(".")[0].capitalize() if host else "?"
def _normalize_research_item(raw: dict[str, Any]) -> dict[str, Any] | None:
title = str(raw.get("title") or "").strip()
link = _normalize_link(str(raw.get("link") or ""))
if not title or not link or not link.startswith("http"):
return None
desc = brief_news_summary(str(raw.get("desc_short") or raw.get("summary") or ""))
return {
"title": title,
"link": link,
"source_name": _guess_source_name(link, str(raw.get("source_name") or "")),
"published_fmt": str(raw.get("published_fmt") or "").strip(),
"desc_short": desc,
"summary_plain": desc,
}
def research_tech_limit() -> int:
return wecom_ai_news_tech_limit()
def _parse_items_array(
items_raw: Any,
*,
limit: int,
seen: set[str],
) -> list[dict[str, Any]]:
if not isinstance(items_raw, list):
return []
out: list[dict[str, Any]] = []
for row in items_raw:
if not isinstance(row, dict):
continue
item = _normalize_research_item(row)
if not item:
continue
if item["link"] in seen:
continue
seen.add(item["link"])
out.append(item)
if len(out) >= limit:
break
return out
def parse_research_response(
raw: str,
*,
limit: int,
tech_limit: int = 0,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
parsed = extract_json_object(raw)
seen: set[str] = set()
items = _parse_items_array(parsed.get("items"), limit=limit, seen=seen)
tech_items = _parse_items_array(parsed.get("tech_items"), limit=tech_limit, seen=seen) if tech_limit else []
return items, tech_items
def _apply_pushed_dedup(items: list[dict[str, Any]], *, date_str: str, limit: int) -> list[dict[str, Any]]:
from daily.config import news_backfill_enabled
from daily.news.sanitize import strip_relax_window_prefix
for item in items:
if item.get("desc_short"):
item["desc_short"] = strip_relax_window_prefix(str(item.get("desc_short") or ""))
fresh = filter_unpushed_items(items, date_str=date_str)
if len(fresh) >= limit:
return fresh[:limit]
if not news_backfill_enabled():
if len(fresh) < limit:
logger.info("news_short:%s", len(fresh))
return fresh[:limit]
seen = {i.get("link") for i in fresh}
for item in items:
if len(fresh) >= limit:
break
if item.get("link") not in seen:
fresh.append(item)
seen.add(item.get("link"))
return fresh[:limit]
def fetch_ai_news_research(
*,
date_str: str,
hours: int | None = None,
limit: int | None = None,
) -> dict[str, Any]:
"""Cursor Agent 调研 AI 时讯;返回 {enabled, mode, hours, items, flat, stats}。"""
h = hours if hours is not None else research_hours()
lim = limit if limit is not None else research_limit()
tech_lim = research_tech_limit()
if not has_cursor_configured():
logger.warning("DAILY_AI_NEWS_MODE=research 但未配置 CURSOR_API_KEY")
return {
"enabled": False,
"mode": "research",
"items": [],
"tech_items": [],
"flat": [],
"stats": {"error": "no_cursor_key"},
}
skill = _load_skill()
now_cst = datetime.now(timezone(timedelta(hours=8)))
tech_clause = ""
if tech_lim:
tech_clause = (
f"\n另输出 **tech_items 恰好 {tech_lim} 条**,聚焦工程技术:"
"模型/框架发布、开源项目、芯片算力、开发者工具、推理与工程实践。"
"与 items 不得重复 link。"
)
system = (
f"{skill}\n\n"
"当前执行 **早报 AI 时讯调研**。\n"
f"时间窗口:近 **{h}** 小时(截至 {now_cst.strftime('%Y-%m-%d %H:%M')} UTC+8\n"
f"输出 **恰好 {lim} 条** items按重要性排序。{tech_clause}\n"
"使用 WebSearch 检索;不要读取本项目文档或 RSS 配置。"
)
user = (
f"/deep-research 获取近 {h} 小时的 AI 人工智能新闻资讯,"
"不区分国内国外,合并精选。"
f"只输出 JSONitems 长度={lim}"
+ (f"tech_items 长度={tech_lim}" if tech_lim else "")
+ ""
)
try:
raw = cursor_agent_prompt(system, user)
except Exception as exc:
logger.warning("AI 时讯 research 失败:%s", exc)
return {
"enabled": False,
"mode": "research",
"items": [],
"tech_items": [],
"flat": [],
"stats": {"error": str(exc)},
}
if not raw:
return {
"enabled": False,
"mode": "research",
"items": [],
"tech_items": [],
"flat": [],
"stats": {"error": "empty_response"},
}
items, tech_items = parse_research_response(raw, limit=lim, tech_limit=tech_lim)
payload = extract_json_object(raw)
if payload:
_save_research_json(research_json_path(date_str), payload)
if not items and not tech_items:
logger.warning("AI 时讯 research JSON 无效或无条目")
return {
"enabled": False,
"mode": "research",
"items": [],
"tech_items": [],
"flat": [],
"stats": {"error": "invalid_json"},
}
items = _apply_pushed_dedup(items, date_str=date_str, limit=lim)
if tech_items:
tech_items = _apply_pushed_dedup(tech_items, date_str=date_str, limit=tech_lim)
logger.info("AI 时讯 research 完成:%d 条 + %d 技术", len(items), len(tech_items))
flat = [
{
"title": i["title"],
"link": i["link"],
"summary": i.get("summary_plain") or i.get("desc_short") or "",
"source_name": i["source_name"],
"published_fmt": i.get("published_fmt") or "",
"category_id": "research",
"category_name": "Deep Research",
"category_icon": "🔍",
}
for i in items + tech_items
]
return {
"enabled": True,
"mode": "research",
"hours": h,
"items": items,
"tech_items": tech_items,
"flat": flat,
"stats": {"source": "cursor_research", "items": len(items), "tech_items": len(tech_items)},
}
def format_research_news_section(
research: dict[str, Any],
*,
section_no: int,
wecom_limit: int | None = None,
) -> list[str]:
if not research.get("enabled"):
hint = research.get("stats", {}).get("error", "调研失败或未配置 CURSOR_API_KEY")
return [
"---",
"",
f"## {section_no}、AI 时讯精选Deep Research",
"",
f"*不可用:{hint}*",
"",
]
hours = research.get("hours", 24)
items = (research.get("flat") or [])[: wecom_limit or research_limit()]
lines = [
"---",
"",
f"## {section_no}、AI 时讯精选Deep Research",
"",
f"> 近 **{hours}h** · Cursor Agent WebSearch · {len(items)}",
"",
]
if not items:
lines.append("*暂无可用条目。*")
lines.append("")
return lines
for i, item in enumerate(items, 1):
pub = f" · {item['published_fmt']}" if item.get("published_fmt") else ""
lines.append(
f"{i}. **[{item['title']}]({item['link']})** · `{item['source_name']}`{pub}"
)
summary = item.get("summary") or ""
if summary:
lines.append(f" - {summary}")
lines.append("")
return lines