|
| 1 | +"""User prompt pattern analysis for personalized shortcut suggestions. |
| 2 | +
|
| 3 | +Analyzes prompt patterns locally to suggest matching shortcuts/skills. |
| 4 | +Privacy-first: stores only category counts, never raw prompts. |
| 5 | +Opt-in via ``promptPatternAnalysis.enabled`` in codingbuddy.config.json. |
| 6 | +""" |
| 7 | + |
| 8 | +import json |
| 9 | +import logging |
| 10 | +import os |
| 11 | +from typing import Any, Dict, List, Optional |
| 12 | + |
| 13 | +logger = logging.getLogger(__name__) |
| 14 | + |
| 15 | +PATTERNS_FILE = "patterns.json" |
| 16 | + |
| 17 | +# Keyword-to-category mapping for prompt classification. |
| 18 | +# Order matters: first match wins. |
| 19 | +CATEGORY_KEYWORDS: Dict[str, List[str]] = { |
| 20 | + "test": [ |
| 21 | + "test", |
| 22 | + "테스트", |
| 23 | + "pytest", |
| 24 | + "jest", |
| 25 | + "spec", |
| 26 | + "coverage", |
| 27 | + "assertion", |
| 28 | + ], |
| 29 | + "debug": [ |
| 30 | + "fix", |
| 31 | + "bug", |
| 32 | + "error", |
| 33 | + "debug", |
| 34 | + "디버그", |
| 35 | + "수정", |
| 36 | + "오류", |
| 37 | + "issue", |
| 38 | + "broken", |
| 39 | + "crash", |
| 40 | + "fail", |
| 41 | + ], |
| 42 | + "refactor": [ |
| 43 | + "refactor", |
| 44 | + "리팩토링", |
| 45 | + "리팩터", |
| 46 | + "clean", |
| 47 | + "restructure", |
| 48 | + "simplify", |
| 49 | + "extract", |
| 50 | + ], |
| 51 | + "build": [ |
| 52 | + "build", |
| 53 | + "compile", |
| 54 | + "빌드", |
| 55 | + "bundle", |
| 56 | + "webpack", |
| 57 | + "vite", |
| 58 | + "esbuild", |
| 59 | + ], |
| 60 | + "deploy": [ |
| 61 | + "deploy", |
| 62 | + "배포", |
| 63 | + "release", |
| 64 | + "ship", |
| 65 | + "publish", |
| 66 | + "production", |
| 67 | + ], |
| 68 | + "review": [ |
| 69 | + "review", |
| 70 | + "리뷰", |
| 71 | + "pr", |
| 72 | + "pull request", |
| 73 | + "코드 리뷰", |
| 74 | + "check", |
| 75 | + ], |
| 76 | + "docs": [ |
| 77 | + "doc", |
| 78 | + "문서", |
| 79 | + "readme", |
| 80 | + "comment", |
| 81 | + "documentation", |
| 82 | + "jsdoc", |
| 83 | + ], |
| 84 | + "create": [ |
| 85 | + "create", |
| 86 | + "생성", |
| 87 | + "new", |
| 88 | + "add", |
| 89 | + "만들", |
| 90 | + "component", |
| 91 | + "feature", |
| 92 | + "implement", |
| 93 | + ], |
| 94 | + "git": [ |
| 95 | + "commit", |
| 96 | + "push", |
| 97 | + "merge", |
| 98 | + "branch", |
| 99 | + "rebase", |
| 100 | + "cherry-pick", |
| 101 | + "stash", |
| 102 | + ], |
| 103 | + "security": [ |
| 104 | + "security", |
| 105 | + "보안", |
| 106 | + "vulnerab", |
| 107 | + "auth", |
| 108 | + "permission", |
| 109 | + "xss", |
| 110 | + "injection", |
| 111 | + "csrf", |
| 112 | + ], |
| 113 | + "performance": [ |
| 114 | + "performance", |
| 115 | + "성능", |
| 116 | + "optimi", |
| 117 | + "slow", |
| 118 | + "fast", |
| 119 | + "latency", |
| 120 | + "memory", |
| 121 | + "cache", |
| 122 | + ], |
| 123 | +} |
| 124 | + |
| 125 | +# Shortcut/skill suggestions per category. |
| 126 | +_SHORTCUT_MAP: Dict[str, Dict[str, str]] = { |
| 127 | + "test": { |
| 128 | + "shortcut": "/tdd", |
| 129 | + "skill": "superpowers:test-driven-development", |
| 130 | + "description": "Run TDD workflow for test-first development", |
| 131 | + }, |
| 132 | + "debug": { |
| 133 | + "shortcut": "/debug", |
| 134 | + "skill": "superpowers:systematic-debugging", |
| 135 | + "description": "Systematic debugging with root cause analysis", |
| 136 | + }, |
| 137 | + "refactor": { |
| 138 | + "shortcut": "/simplify", |
| 139 | + "skill": "simplify", |
| 140 | + "description": "Review and simplify code for quality", |
| 141 | + }, |
| 142 | + "build": { |
| 143 | + "shortcut": "/build-fix", |
| 144 | + "skill": "oh-my-claudecode:build-fix", |
| 145 | + "description": "Fix build and compilation errors", |
| 146 | + }, |
| 147 | + "deploy": { |
| 148 | + "shortcut": "/ship", |
| 149 | + "skill": "ship", |
| 150 | + "description": "Run CI checks and ship changes", |
| 151 | + }, |
| 152 | + "review": { |
| 153 | + "shortcut": "/code-review", |
| 154 | + "skill": "oh-my-claudecode:code-review", |
| 155 | + "description": "Run comprehensive code review", |
| 156 | + }, |
| 157 | + "docs": { |
| 158 | + "shortcut": "/plan", |
| 159 | + "skill": "oh-my-claudecode:plan", |
| 160 | + "description": "Plan documentation structure", |
| 161 | + }, |
| 162 | + "create": { |
| 163 | + "shortcut": "/brainstorm", |
| 164 | + "skill": "superpowers:brainstorming", |
| 165 | + "description": "Brainstorm before creating new features", |
| 166 | + }, |
| 167 | + "git": { |
| 168 | + "shortcut": "/git-master", |
| 169 | + "skill": "oh-my-claudecode:git-master", |
| 170 | + "description": "Git expert for commits and history management", |
| 171 | + }, |
| 172 | + "security": { |
| 173 | + "shortcut": "/security-review", |
| 174 | + "skill": "oh-my-claudecode:security-review", |
| 175 | + "description": "Run comprehensive security review", |
| 176 | + }, |
| 177 | + "performance": { |
| 178 | + "shortcut": "/analyze", |
| 179 | + "skill": "oh-my-claudecode:analyze", |
| 180 | + "description": "Deep analysis and investigation", |
| 181 | + }, |
| 182 | +} |
| 183 | + |
| 184 | + |
| 185 | +def classify_prompt(prompt: str) -> str: |
| 186 | + """Classify a prompt into a category using keyword matching. |
| 187 | +
|
| 188 | + Privacy: only returns a category string, never stores the prompt. |
| 189 | +
|
| 190 | + Args: |
| 191 | + prompt: Raw user prompt text. |
| 192 | +
|
| 193 | + Returns: |
| 194 | + Category string (e.g. "test", "debug") or "other". |
| 195 | + """ |
| 196 | + if not prompt: |
| 197 | + return "other" |
| 198 | + lower = prompt.lower() |
| 199 | + for category, keywords in CATEGORY_KEYWORDS.items(): |
| 200 | + for kw in keywords: |
| 201 | + if kw in lower: |
| 202 | + return category |
| 203 | + return "other" |
| 204 | + |
| 205 | + |
| 206 | +class PromptPatternAnalyzer: |
| 207 | + """Analyzes user prompt patterns and suggests personalized shortcuts. |
| 208 | +
|
| 209 | + Stores only category frequency counts locally. Never stores raw prompts. |
| 210 | + Must be explicitly enabled via config (opt-in). |
| 211 | + """ |
| 212 | + |
| 213 | + def __init__(self, data_dir: str, enabled: bool = False) -> None: |
| 214 | + self._data_dir = data_dir |
| 215 | + self._enabled = enabled |
| 216 | + self._categories: Dict[str, int] = {} |
| 217 | + self._total: int = 0 |
| 218 | + |
| 219 | + if self._enabled: |
| 220 | + os.makedirs(self._data_dir, exist_ok=True) |
| 221 | + self._load() |
| 222 | + |
| 223 | + @property |
| 224 | + def enabled(self) -> bool: |
| 225 | + return self._enabled |
| 226 | + |
| 227 | + @classmethod |
| 228 | + def from_config( |
| 229 | + cls, |
| 230 | + config: Dict[str, Any], |
| 231 | + data_dir: str, |
| 232 | + ) -> "PromptPatternAnalyzer": |
| 233 | + """Create an analyzer from codingbuddy config. |
| 234 | +
|
| 235 | + Args: |
| 236 | + config: Parsed codingbuddy.config.json dict. |
| 237 | + data_dir: Default data directory path. |
| 238 | +
|
| 239 | + Returns: |
| 240 | + Configured PromptPatternAnalyzer instance. |
| 241 | + """ |
| 242 | + ppa_config = config.get("promptPatternAnalysis", {}) |
| 243 | + enabled = ppa_config.get("enabled", False) |
| 244 | + custom_dir = ppa_config.get("dataDir", None) |
| 245 | + return cls( |
| 246 | + data_dir=custom_dir or data_dir, |
| 247 | + enabled=bool(enabled), |
| 248 | + ) |
| 249 | + |
| 250 | + def record_prompt(self, prompt: str) -> None: |
| 251 | + """Record a prompt by classifying and incrementing the category count. |
| 252 | +
|
| 253 | + Args: |
| 254 | + prompt: Raw user prompt text (not stored). |
| 255 | + """ |
| 256 | + if not self._enabled: |
| 257 | + return |
| 258 | + |
| 259 | + category = classify_prompt(prompt) |
| 260 | + self._categories[category] = self._categories.get(category, 0) + 1 |
| 261 | + self._total += 1 |
| 262 | + self._save() |
| 263 | + |
| 264 | + def analyze_patterns(self, top_n: int = 10) -> List[Dict[str, Any]]: |
| 265 | + """Return top N most frequent prompt categories. |
| 266 | +
|
| 267 | + Args: |
| 268 | + top_n: Maximum number of patterns to return. |
| 269 | +
|
| 270 | + Returns: |
| 271 | + List of dicts with keys: category, count, percentage. |
| 272 | + Sorted by count descending. |
| 273 | + """ |
| 274 | + if not self._enabled or not self._categories: |
| 275 | + return [] |
| 276 | + |
| 277 | + sorted_cats = sorted( |
| 278 | + self._categories.items(), key=lambda x: x[1], reverse=True |
| 279 | + )[:top_n] |
| 280 | + |
| 281 | + total = self._total if self._total > 0 else 1 |
| 282 | + return [ |
| 283 | + { |
| 284 | + "category": cat, |
| 285 | + "count": count, |
| 286 | + "percentage": round((count / total) * 100, 1), |
| 287 | + } |
| 288 | + for cat, count in sorted_cats |
| 289 | + ] |
| 290 | + |
| 291 | + def suggest_shortcuts( |
| 292 | + self, min_count: int = 3 |
| 293 | + ) -> List[Dict[str, Any]]: |
| 294 | + """Suggest shortcuts/skills based on frequent patterns. |
| 295 | +
|
| 296 | + Args: |
| 297 | + min_count: Minimum category count to trigger a suggestion. |
| 298 | +
|
| 299 | + Returns: |
| 300 | + List of suggestion dicts with keys: |
| 301 | + category, count, shortcut, skill, description. |
| 302 | + """ |
| 303 | + if not self._enabled: |
| 304 | + return [] |
| 305 | + |
| 306 | + suggestions = [] |
| 307 | + for cat, count in sorted( |
| 308 | + self._categories.items(), key=lambda x: x[1], reverse=True |
| 309 | + ): |
| 310 | + if count < min_count: |
| 311 | + continue |
| 312 | + mapping = _SHORTCUT_MAP.get(cat) |
| 313 | + if mapping is None: |
| 314 | + continue |
| 315 | + suggestions.append( |
| 316 | + { |
| 317 | + "category": cat, |
| 318 | + "count": count, |
| 319 | + **mapping, |
| 320 | + } |
| 321 | + ) |
| 322 | + return suggestions |
| 323 | + |
| 324 | + def _load(self) -> None: |
| 325 | + """Load pattern data from disk.""" |
| 326 | + path = os.path.join(self._data_dir, PATTERNS_FILE) |
| 327 | + if not os.path.isfile(path): |
| 328 | + return |
| 329 | + try: |
| 330 | + with open(path, "r", encoding="utf-8") as f: |
| 331 | + data = json.load(f) |
| 332 | + self._categories = data.get("categories", {}) |
| 333 | + self._total = data.get("total", 0) |
| 334 | + except (json.JSONDecodeError, OSError) as e: |
| 335 | + logger.warning("Failed to load pattern data: %s", e) |
| 336 | + |
| 337 | + def _save(self) -> None: |
| 338 | + """Persist pattern data to disk.""" |
| 339 | + path = os.path.join(self._data_dir, PATTERNS_FILE) |
| 340 | + try: |
| 341 | + with open(path, "w", encoding="utf-8") as f: |
| 342 | + json.dump( |
| 343 | + {"categories": self._categories, "total": self._total}, |
| 344 | + f, |
| 345 | + indent=2, |
| 346 | + ) |
| 347 | + except OSError as e: |
| 348 | + logger.warning("Failed to save pattern data: %s", e) |
0 commit comments