"""今日首推:解析 DAILY_FEATURED_PICK → 定人(月去重)→ LLM 检索 → featured JSON。""" from __future__ import annotations import hashlib import json import logging import random import re from datetime import datetime, timedelta from typing import Any from daily.config import OUTPUT_DIR, ROOT, env, featured_dedup_days from daily.llm_client import extract_json_object, has_llm_configured, llm_chat from daily.report_data import featured_json_path, save_json logger = logging.getLogger(__name__) _SKILL_DIR = ROOT / "skills" / "daily-featured-pick" _GITHUB_REPO_RE = re.compile(r"github\.com/([^/\s#?]+/[^/\s#?]+)", re.I) def featured_identity_key(featured: dict[str, Any] | None) -> str: """稳定身份:skill→id,github→repo,兜底从 url 解析。""" if not featured: return "" typ = str(featured.get("type") or "").lower() if typ == "skill" or featured.get("id"): sid = str(featured.get("id") or "").strip() if sid: return sid repo = str(featured.get("repo") or "").strip() if repo: return repo for field in ("url", "command", "link"): url = str(featured.get(field) or "") m = _GITHUB_REPO_RE.search(url) if m: return m.group(1) return "" def load_recent_featured_keys(date_str: str, days: int | None = None) -> set[str]: """近 N 日 data.featured_pick_key 并集(不含当日)。""" lookback = days if days is not None else featured_dedup_days() try: dt = datetime.strptime(date_str, "%Y-%m-%d") except ValueError: return set() out: set[str] = set() for day_offset in range(1, lookback + 1): prev = (dt - timedelta(days=day_offset)).strftime("%Y-%m-%d") path = OUTPUT_DIR / f"{prev}.data.json" if not path.exists(): continue try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: logger.warning("读取 featured_pick_key %s 失败:%s", path, exc) continue data = payload.get("data") if isinstance(payload, dict) else None if not isinstance(data, dict): continue key = str(data.get("featured_pick_key") or "").strip() if not key: featured = data.get("featured_pick") if isinstance(featured, dict): key = featured_identity_key(featured) if key: out.add(key) return out def load_yesterday_featured_key(date_str: str) -> str | None: try: dt = datetime.strptime(date_str, "%Y-%m-%d") except ValueError: return None prev = (dt - timedelta(days=1)).strftime("%Y-%m-%d") path = OUTPUT_DIR / f"{prev}.data.json" if not path.exists(): return None try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return None data = payload.get("data") if isinstance(payload, dict) else None if not isinstance(data, dict): return None key = str(data.get("featured_pick_key") or "").strip() if key: return key featured = data.get("featured_pick") if isinstance(featured, dict): return featured_identity_key(featured) or None return None def _featured_rng(date_str: str) -> random.Random: seed = int(hashlib.sha256(f"{date_str}:featured".encode()).hexdigest()[:16], 16) return random.Random(seed) def _stub_from_pool_item(item: dict[str, Any]) -> dict[str, Any]: repo = str(item.get("repo") or "").strip() if repo: url = str(item.get("url") or f"https://github.com/{repo}").strip() return { "type": "github", "title": repo.split("/")[-1], "repo": repo, "url": url, "command": url, "summary": str(item.get("description") or "")[:160], "why_today": "", "evidence": [], "tags": [], } sid = str(item.get("id") or "").strip() source = str(item.get("source") or "").strip() title = str(item.get("title") or "").strip() return { "type": "skill", "id": sid, "title": title or sid, "command": _skill_command(item), "url": str(item.get("link") or ""), "summary": str(item.get("description") or "")[:160], "why_today": "", "evidence": [], "tags": [], "source": source, } def featured_resolve( *, date_str: str, candidate: dict[str, Any] | None, pool_a: list[dict[str, Any]], pool_b: list[dict[str, Any]], recent_featured: set[str] | None = None, yesterday_key: str | None = None, rng: random.Random | None = None, ) -> tuple[dict[str, Any] | None, str | None]: """若与昨日同一身份则改推;返回 (seed_stub, identity_key),不含完整 why。""" if not candidate: return None, None key = featured_identity_key(candidate) if not yesterday_key or key != yesterday_key: return candidate, key or None blocked = set(recent_featured or set()) | {yesterday_key} picker = rng or _featured_rng(date_str) def _choices(pool: list[dict[str, Any]]) -> list[tuple[str, dict[str, Any]]]: out: list[tuple[str, dict[str, Any]]] = [] seen: set[str] = set() for item in pool: ik = featured_identity_key(item) if not ik or ik in blocked or ik in seen: continue seen.add(ik) out.append((ik, item)) return out options = _choices(pool_a) if not options: options = _choices(pool_b) if not options: logger.info("featured_fallback_exhausted") return candidate, key chosen_key, chosen_item = picker.choice(options) return _stub_from_pool_item(chosen_item), chosen_key def _config_from_candidate(candidate: dict[str, Any]) -> dict[str, str]: typ = str(candidate.get("type") or "").lower() if typ == "skill" or candidate.get("id"): query = str(candidate.get("id") or candidate.get("title") or "").strip() return {"query": query, "url_hint": str(candidate.get("url") or "")} repo = str(candidate.get("repo") or "").strip() if repo: return { "query": repo, "url_hint": str(candidate.get("url") or f"https://github.com/{repo}"), } query = str(candidate.get("title") or candidate.get("url") or "").strip() return {"query": query or "featured", "url_hint": str(candidate.get("url") or "")} def _seed_candidate_from_config( config: dict[str, str], llm_input: dict[str, Any], ) -> dict[str, Any]: matches = match_in_data(llm_input, config["query"]) if matches["skills"]: return _stub_from_pool_item(matches["skills"][0]) if matches["github"]: return _stub_from_pool_item(matches["github"][0]) url = config.get("url_hint") or "" seed: dict[str, Any] = { "type": "other", "title": config["query"], "url": url, "command": url or config["query"], } m = _GITHUB_REPO_RE.search(url) if m: seed["type"] = "github" seed["repo"] = m.group(1) return seed def parse_featured_pick() -> dict[str, str] | None: """解析 DAILY_FEATURED_PICK:query 或 query|url。""" raw = (env("DAILY_FEATURED_PICK") or "").strip() if not raw: return None if "|" in raw: query, url_hint = raw.split("|", 1) query = query.strip() url_hint = url_hint.strip() if not query: return None payload: dict[str, str] = {"query": query} if url_hint: payload["url_hint"] = url_hint return payload return {"query": raw} def _matches_query(text: str, query: str) -> bool: return query.lower() in (text or "").lower() def _skill_matches(item: dict[str, Any], query: str) -> bool: for key in ("id", "title", "source"): if _matches_query(str(item.get(key) or ""), query): return True for sub in item.get("cluster_skills") or []: if isinstance(sub, str) and _matches_query(sub, query): return True return False def match_in_data(llm_input: dict[str, Any], query: str) -> dict[str, list[dict[str, Any]]]: """在榜单数据中模糊匹配 query。""" skills: list[dict[str, Any]] = [] seen_skill: set[str] = set() for board in ("skills_trending", "skills_hot"): for item in llm_input.get(board) or []: sid = str(item.get("id") or "") if sid in seen_skill: continue if _skill_matches(item, query): seen_skill.add(sid) skills.append({**item, "board": board}) if len(skills) >= 5: break if len(skills) >= 5: break github: list[dict[str, Any]] = [] seen_repo: set[str] = set() for board in ("github_trending", "github_emerging"): for item in llm_input.get(board) or []: repo = str(item.get("repo") or "") if not repo or repo in seen_repo: continue if _matches_query(repo, query): seen_repo.add(repo) github.append({**item, "board": board}) if len(github) >= 5: break topic = llm_input.get("github_topic") or {} for item in topic.get("repos") or []: repo = str(item.get("repo") or "") if not repo or repo in seen_repo: continue if _matches_query(repo, query): seen_repo.add(repo) github.append({**item, "board": "github_topic"}) if len(github) >= 5: break return {"skills": skills, "github": github} def _load_skill() -> str: path = _SKILL_DIR / "SKILL.md" if path.exists(): return path.read_text(encoding="utf-8").strip() return "你是早报编辑。根据输入检索今日首推信息,只输出 JSON。" def _skill_command(item: dict[str, Any]) -> str: source = str(item.get("source") or "").strip() title = str(item.get("title") or "").strip() if source and title: return f"npx skills add {source}/{title}" sid = str(item.get("id") or "").strip() if sid.count("/") >= 2: parts = sid.split("/", 2) return f"npx skills add {parts[0]}/{parts[1]}/{parts[2]}" if sid.count("/") == 1: return f"npx skills add {sid}" return "" def _evidence_from_skill(item: dict[str, Any]) -> list[str]: board = item.get("board", "") board_label = { "skills_trending": "Skills Trending", "skills_hot": "Skills Hot", }.get(str(board), str(board)) installs = item.get("installs_fmt") or item.get("installs") title = item.get("title") or item.get("id") or "?" if installs: return [f"{board_label} 匹配 · {title} · {installs}"] return [f"{board_label} 匹配 · {title}"] def _evidence_from_github(item: dict[str, Any]) -> list[str]: board = item.get("board", "") board_label = { "github_trending": "GitHub Trending", "github_emerging": "GitHub 新兴", "github_topic": "GitHub Topic", }.get(str(board), str(board)) repo = item.get("repo") or "?" stars = item.get("total_stars_fmt") or "" if stars: return [f"{board_label} 匹配 · {repo} · ⭐{stars}"] return [f"{board_label} 匹配 · {repo}"] def _fallback_featured( config: dict[str, str], llm_input: dict[str, Any], *, partial: dict[str, Any] | None = None, ) -> dict[str, Any]: """LLM 不可用或失败时,用榜单匹配 + 配置回退。""" partial = partial or {} matches = match_in_data(llm_input, config["query"]) skill = matches["skills"][0] if matches["skills"] else None gh = matches["github"][0] if matches["github"] else None if skill: featured: dict[str, Any] = { "title": str(skill.get("title") or config["query"]), "type": "skill", "command": _skill_command(skill), "url": str(skill.get("link") or config.get("url_hint") or ""), "summary": str(partial.get("summary") or skill.get("description") or "")[:160], "why_today": str( partial.get("why_today") or f"今日 Skills 榜匹配到 **{skill.get('title') or config['query']}**,适合作为首推。" ), "evidence": list(partial.get("evidence") or _evidence_from_skill(skill)), "tags": list(partial.get("tags") or []), } if skill.get("id"): featured["id"] = skill["id"] return featured if gh: return { "title": str(gh.get("repo") or config["query"]).split("/")[-1], "type": "github", "command": str(gh.get("url") or config.get("url_hint") or ""), "url": str(gh.get("url") or config.get("url_hint") or ""), "summary": str(partial.get("summary") or gh.get("description") or "")[:160], "why_today": str( partial.get("why_today") or f"今日 GitHub 榜匹配到 **{gh.get('repo')}**,适合作为首推。" ), "evidence": list(partial.get("evidence") or _evidence_from_github(gh)), "tags": list(partial.get("tags") or []), "repo": gh.get("repo"), } url = config.get("url_hint") or "" return { "title": config["query"], "type": "other", "command": url or config["query"], "url": url, "summary": str(partial.get("summary") or "")[:160], "why_today": str( partial.get("why_today") or f"**{config['query']}** 未出现在今日 Top 榜,仍值得单独关注。" ), "evidence": list(partial.get("evidence") or ([f"主推 · {config['query']}"])), "tags": list(partial.get("tags") or []), } def _normalize_featured( raw: dict[str, Any], config: dict[str, str], llm_input: dict[str, Any], ) -> dict[str, Any]: """补齐 command / url / evidence,并与榜单数字对齐。""" matches = match_in_data(llm_input, config["query"]) skill = matches["skills"][0] if matches["skills"] else None gh = matches["github"][0] if matches["github"] else None featured = dict(raw) featured.setdefault("title", config["query"]) featured.setdefault("type", "other") if skill and featured.get("type") in {"skill", "other", ""}: featured.setdefault("id", skill.get("id")) featured.setdefault("command", _skill_command(skill)) featured.setdefault("url", skill.get("link") or config.get("url_hint") or "") if not featured.get("evidence"): featured["evidence"] = _evidence_from_skill(skill) featured["type"] = "skill" elif gh and featured.get("type") in {"github", "other", ""}: featured.setdefault("repo", gh.get("repo")) featured.setdefault("url", gh.get("url") or config.get("url_hint") or "") featured.setdefault("command", featured.get("url") or gh.get("url") or "") if not featured.get("evidence"): featured["evidence"] = _evidence_from_github(gh) featured["type"] = "github" featured.setdefault("command", config.get("url_hint") or config["query"]) featured.setdefault("url", config.get("url_hint") or "") featured.setdefault("summary", "") featured.setdefault("why_today", featured.get("summary") or "") featured.setdefault("evidence", []) featured.setdefault("tags", []) return featured def research_featured_pick( llm_input: dict[str, Any], *, date_str: str, config: dict[str, str] | None = None, ) -> dict[str, Any] | None: """Step 0:检索今日首推;成功返回 featured dict,未配置返回 None。""" config = config or parse_featured_pick() if not config: return None matches = match_in_data(llm_input, config["query"]) payload = { "query": config["query"], "url_hint": config.get("url_hint"), "cwd": env("DAILY_CURSOR_CWD") or str(ROOT), "data_matches": matches, } if not has_llm_configured(): featured = _fallback_featured(config, llm_input) save_json(featured_json_path(date_str), featured) logger.info("Featured pick(无 LLM,规则回退):%s", featured.get("title")) return featured skill = _load_skill() system = ( f"{skill}\n\n" "当前执行 **Step 0:今日首推检索**。\n" "只输出 featured JSON(title, type, command, url, summary, why_today, evidence, tags)," "不要 Markdown,不要解释。" ) user = json.dumps(payload, ensure_ascii=False, indent=2) try: raw = llm_chat(system, user) except Exception as exc: logger.warning("Featured pick LLM 失败,回退规则模式:%s", exc) featured = _fallback_featured(config, llm_input) save_json(featured_json_path(date_str), featured) return featured if not raw: featured = _fallback_featured(config, llm_input) save_json(featured_json_path(date_str), featured) return featured parsed = extract_json_object(raw) if not parsed.get("why_today") and not parsed.get("summary"): logger.warning("Featured pick JSON 无效,回退规则模式") featured = _fallback_featured(config, llm_input, partial=parsed) save_json(featured_json_path(date_str), featured) return featured featured = _normalize_featured(parsed, config, llm_input) save_json(featured_json_path(date_str), featured) logger.info("Featured pick 完成:%s", featured.get("title")) return featured def apply_featured_pick( llm_input: dict[str, Any], *, date_str: str, pool_a: list[dict[str, Any]] | None = None, pool_b: list[dict[str, Any]] | None = None, ) -> dict[str, Any] | None: """先定人(相对昨日改推 + 月去重),再 research,写入 featured_pick / featured_pick_key。""" config = parse_featured_pick() if not config: return None seed = _seed_candidate_from_config(config, llm_input) recent = load_recent_featured_keys(date_str) yesterday = load_yesterday_featured_key(date_str) resolved, identity_key = featured_resolve( date_str=date_str, candidate=seed, pool_a=pool_a or [], pool_b=pool_b or [], recent_featured=recent, yesterday_key=yesterday, rng=_featured_rng(date_str), ) research_config = config if resolved and identity_key and featured_identity_key(seed) != identity_key: research_config = _config_from_candidate(resolved) featured = research_featured_pick( llm_input, date_str=date_str, config=research_config ) if featured: llm_input["featured_pick"] = featured key = identity_key or featured_identity_key(featured) if key: llm_input["featured_pick_key"] = key return featured def pick_command_from_featured(featured: dict[str, Any] | None) -> str | None: cmd = str((featured or {}).get("command") or "").strip() return cmd or None def pick_why_from_featured(featured: dict[str, Any] | None) -> str | None: why = str((featured or {}).get("why_today") or "").strip() return why or None