| 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 |