"""叙事轴硬互斥:代码选定轴,注入 Agent Step1 并强制覆写。""" from __future__ import annotations import json import logging import random from collections import defaultdict from datetime import datetime, timedelta from typing import Any from daily.config import OUTPUT_DIR, narrative_axis_days logger = logging.getLogger(__name__) NARRATIVE_AXES: tuple[str, ...] = ( "政策监管", "模型发布", "工具链/Agent", "芯片算力", "开源生态", "应用落地", "安全/诉讼", ) def pick_narrative_axis( used: set[str], *, rng: random.Random | None = None, ) -> str: """从固定轴枚举中排除已用轴后随机选取;全用尽则回退全表。""" available = [a for a in NARRATIVE_AXES if a not in used] pool = available or list(NARRATIVE_AXES) picker = rng or random.Random() return picker.choice(pool) def load_recent_axes(date_str: str, days: int | None = None) -> list[str]: """近 N 日 data.narrative_axis(不含当日,按时间从近到远)。""" lookback = days if days is not None else narrative_axis_days() try: dt = datetime.strptime(date_str, "%Y-%m-%d") except ValueError: return [] axes: list[str] = [] 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("读取 narrative_axis %s 失败:%s", path, exc) continue data = payload.get("data") if isinstance(payload, dict) else None if not isinstance(data, dict): continue axis = str(data.get("narrative_axis") or "").strip() if axis: axes.append(axis) return axes def load_recent_theme_summaries(date_str: str, days: int) -> list[str]: """近 N 日 theme/opening 摘要,供 Step1 软禁参考。""" try: dt = datetime.strptime(date_str, "%Y-%m-%d") except ValueError: return [] summaries: list[str] = [] for day_offset in range(1, days + 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): continue data = payload.get("data") if isinstance(payload, dict) else None if not isinstance(data, dict): continue theme = str(data.get("theme") or data.get("editorial_theme") or "").strip() opening = "" trends = data.get("trends") if isinstance(data.get("trends"), dict) else {} if isinstance(trends, dict): opening = str(trends.get("opening") or "").strip() if not theme: themes = trends.get("themes") or [] if themes and isinstance(themes[0], dict): theme = str(themes[0].get("title") or "").strip() bit = " · ".join(x for x in (prev, theme, opening[:40]) if x) if bit: summaries.append(bit) return summaries def enforce_narrative_axis(trends: dict[str, Any], axis: str) -> dict[str, Any]: """强制 trends['narrative_axis'] = axis。""" out = dict(trends) out["narrative_axis"] = axis return out def theme_clusters( feed: dict[str, Any], *, limit: int = 5, theme_rules: list[tuple[str, str, list[str]]], skill_id_fn: Any, ) -> list[tuple[str, list[str]]]: """按 THEME_RULES 把 feed 的 topTrending/topHot 聚成 (主题, 示例列表)。 从 generate.py 迁入(原私有 _theme_clusters)。theme_rules 与 skill_id_fn 由调用方注入,避免对 generate.py 的反向依赖(防循环 import)。 """ buckets: dict[str, list[str]] = defaultdict(list) seen: set[str] = set() for board in ("topTrending", "topHot"): for item in feed.get(board, [])[:20]: item_id = skill_id_fn(item) if item_id in seen: continue seen.add(item_id) haystack = " ".join( [item.get("title", ""), item.get("source", ""), item.get("description", "")] ).lower() for _icon, theme, keywords in theme_rules: if any(k in haystack for k in keywords): label = f"**{item.get('title')}** (`{item.get('source')}`)" if label not in buckets[theme]: buckets[theme].append(label) break return [(theme, examples[:limit]) for theme, examples in buckets.items() if examples] def theme_names( feed: dict[str, Any], *, theme_rules: list[tuple[str, str, list[str]]], skill_id_fn: Any, limit: int = 3, ) -> list[str]: """仅取主题名(不含 markdown 示例),供「今日看点/theme_line」回退文案。""" return [theme for theme, _ in theme_clusters( feed, theme_rules=theme_rules, skill_id_fn=skill_id_fn )][:limit]