返回 last30days-skill
categories.py
根目录 / skills / last30days / scripts / lib / categories.py
1 """Category-peer subreddit map for Step 0.55 community resolution.
2
3 When a topic is a product in a known category (AI image generation, AI coding
4 agents, SaaS screen recording, etc.), brand-specific subreddits returned by
5 WebSearch are insufficient: cross-product technique discussion lives in
6 category-peer subs. This module classifies a topic into a category by matching
7 compound-term patterns against the lowercased topic string, then returns the
8 priority-ordered peer subreddit list for that category.
9
10 The map is intentionally small, curated, and code-reviewed. Adding a new
11 category is a code change; there is no user-editable override surface.
12
13 False-positive guard: every pattern is either a multi-word compound (e.g.
14 "image generation", "text to image") or a domain-specific single word
15 (e.g. "midjourney", "stablediffusion"). Bare common nouns like "image",
16 "ai", or "model" are never used as patterns.
17
18 First-match-wins: categories are evaluated in declared order. Entries are
19 sorted from most-specific to least-specific so narrower categories claim a
20 topic before broader ones. For example, `ai_image_generation` appears
21 before `ai_chat_model` so "gpt image 2" matches the image-gen category.
22 """
23
24 from __future__ import annotations
25
26 import re
27 from typing import List, Optional, TypedDict
28
29
30 class _CategoryEntry(TypedDict):
31 patterns: List[str]
32 peer_subs: List[str]
33
34
35 CATEGORY_PEERS: dict[str, _CategoryEntry] = {
36 "ai_image_generation": {
37 "patterns": [
38 "image generation",
39 "image gen",
40 "text to image",
41 "text-to-image",
42 "gpt image",
43 "gpt-image",
44 "nano banana",
45 "midjourney",
46 "stable diffusion",
47 "stablediffusion",
48 "dall-e",
49 "dalle",
50 "flux.1",
51 "flux schnell",
52 "imagen",
53 "seedance",
54 "ideogram",
55 "recraft",
56 ],
57 "peer_subs": [
58 "StableDiffusion",
59 "midjourney",
60 "dalle2",
61 "aiArt",
62 "PromptEngineering",
63 "MediaSynthesis",
64 ],
65 },
66 "ai_video_generation": {
67 "patterns": [
68 "video generation",
69 "text to video",
70 "text-to-video",
71 "sora",
72 "veo 3",
73 "veo3",
74 "runway gen",
75 "kling",
76 "pika labs",
77 "luma dream machine",
78 "hailuo",
79 ],
80 "peer_subs": [
81 "aivideo",
82 "StableDiffusion",
83 "runwayml",
84 "singularity",
85 "MediaSynthesis",
86 ],
87 },
88 "ai_music_generation": {
89 "patterns": [
90 "music generation",
91 "ai music",
92 "suno",
93 "udio",
94 "riffusion",
95 "stable audio",
96 ],
97 "peer_subs": [
98 "SunoAI",
99 "udiomusic",
100 "aimusic",
101 "artificial",
102 ],
103 },
104 "ai_coding_agent": {
105 "patterns": [
106 "claude code",
107 "cursor ide",
108 "github copilot",
109 "windsurf",
110 "aider",
111 "cline",
112 "openclaw",
113 "hermes agent",
114 "continue.dev",
115 "codeium",
116 "sweep ai",
117 "devin ai",
118 "coding agent",
119 "coding assistant",
120 ],
121 "peer_subs": [
122 "ChatGPTCoding",
123 "LocalLLaMA",
124 "singularity",
125 "PromptEngineering",
126 ],
127 },
128 "ai_agent_framework": {
129 "patterns": [
130 "ai agent",
131 "ai agents",
132 "agent framework",
133 "agentic framework",
134 "langchain",
135 "langgraph",
136 "crewai",
137 "autogen",
138 "llamaindex",
139 "dspy",
140 "smolagents",
141 ],
142 "peer_subs": [
143 "LangChain",
144 "LocalLLaMA",
145 "AI_Agents",
146 "MachineLearning",
147 ],
148 },
149 "ai_chat_model": {
150 "patterns": [
151 "gpt-5",
152 "gpt-4",
153 "claude opus",
154 "claude sonnet",
155 "claude haiku",
156 "gemini pro",
157 "gemini flash",
158 "llama 3",
159 "llama 4",
160 "deepseek",
161 "qwen",
162 "mistral large",
163 "grok",
164 ],
165 "peer_subs": [
166 "LocalLLaMA",
167 "ChatGPT",
168 "ClaudeAI",
169 "singularity",
170 "artificial",
171 ],
172 },
173 "saas_screen_recording": {
174 "patterns": [
175 "screen recording",
176 "screen recorder",
177 "loom video",
178 "tella screen",
179 "vidyard",
180 "screen capture tool",
181 ],
182 "peer_subs": [
183 "SaaS",
184 "screenrecording",
185 "productivity",
186 "Entrepreneur",
187 ],
188 },
189 "saas_productivity": {
190 "patterns": [
191 "notion app",
192 "obsidian plugin",
193 "obsidian app",
194 "linear app",
195 "asana",
196 "clickup",
197 "productivity app",
198 ],
199 "peer_subs": [
200 "productivity",
201 "SaaS",
202 "ObsidianMD",
203 "Notion",
204 ],
205 },
206 "prediction_markets": {
207 "patterns": [
208 "polymarket",
209 "kalshi",
210 "prediction market",
211 "event contracts",
212 "manifold markets",
213 ],
214 "peer_subs": [
215 "Polymarket",
216 "Kalshi",
217 "predictionmarkets",
218 ],
219 },
220 "crypto_defi": {
221 "patterns": [
222 "defi protocol",
223 "yield farming",
224 "liquidity pool",
225 "stablecoin",
226 "ethereum layer",
227 "layer 2",
228 "l2 rollup",
229 ],
230 "peer_subs": [
231 "defi",
232 "ethfinance",
233 "CryptoCurrency",
234 "ethereum",
235 ],
236 },
237 "dev_tool_cli": {
238 "patterns": [
239 "cli tool",
240 "command line tool",
241 "terminal app",
242 "dev tool",
243 ],
244 "peer_subs": [
245 "commandline",
246 "programming",
247 "webdev",
248 ],
249 },
250 }
251
252
253 def detect_category(topic: Optional[str]) -> Optional[str]:
254 """Classify a topic into a known category by compound-term match.
255
256 Returns the category id (e.g. "ai_image_generation") or None if no
257 category's patterns match. Matching is case-insensitive substring over
258 the lowercased topic. Declaration order wins (first-match-wins), so the
259 map is ordered from most-specific to least-specific.
260
261 A None or empty topic returns None. Classification never raises on
262 normal string inputs; callers do not need to wrap in try/except for
263 typical paths, though defensive callers may.
264 """
265 if not topic:
266 return None
267 lowered = topic.lower()
268 for category_id, entry in CATEGORY_PEERS.items():
269 for pattern in entry["patterns"]:
270 # Word-boundary match: "ai agent" must not fire on "Dubai agents"
271 # or "Thai agents". Substring matching classified those as
272 # ai_agent_framework and routed discovery to LangChain subreddits.
273 if re.search(rf"(?<![a-z0-9]){re.escape(pattern)}(?![a-z0-9])", lowered):
274 return category_id
275 return None
276
277
278 def peer_subs_for(category_id: Optional[str]) -> List[str]:
279 """Return the priority-ordered peer subreddit list for a category.
280
281 Returns an empty list for None or unknown category ids. The returned
282 list is a fresh copy; callers may safely mutate it.
283 """
284 if not category_id:
285 return []
286 entry = CATEGORY_PEERS.get(category_id)
287 if not entry:
288 return []
289 return list(entry["peer_subs"])
290
290 lines PYTHON