theme_clusters/theme_names 经参数注入 THEME_RULES 与 skill_id_fn, 避免 generate.py 的循环 import(T3,为拆分 generate_report 铺路)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
150 lines
5.1 KiB
Python
150 lines
5.1 KiB
Python
"""叙事轴硬互斥:代码选定轴,注入 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]
|