|
2 | 2 |
|
3 | 3 | Gathers context about the project's own codebase, tests, and open issues, |
4 | 4 | then builds a prompt that asks Claude to suggest ONE self-improvement idea. |
| 5 | +
|
| 6 | +Modes: |
| 7 | +- 'code-fix' (default): patches lint/test/UX bugs in any file. |
| 8 | +- 'generation': patches idea-generation logic only, must declare a target |
| 9 | + metric. Powered by engine/telemetry.py signals. |
5 | 10 | """ |
6 | 11 |
|
| 12 | +from __future__ import annotations |
| 13 | + |
7 | 14 | import json |
8 | 15 | import logging |
| 16 | +import re |
9 | 17 | import subprocess |
10 | 18 | from pathlib import Path |
| 19 | +from typing import TYPE_CHECKING, Literal |
| 20 | + |
| 21 | +if TYPE_CHECKING: |
| 22 | + from project_forge.models import Idea |
| 23 | + from project_forge.storage.db import Database |
11 | 24 |
|
12 | 25 | logger = logging.getLogger(__name__) |
13 | 26 |
|
| 27 | +GENERATION_FILES = ( |
| 28 | + "engine/prompts.py", |
| 29 | + "engine/categories.py", |
| 30 | + "engine/super_ideas.py", |
| 31 | + "engine/router.py", |
| 32 | + "engine/dedup.py", |
| 33 | +) |
| 34 | + |
14 | 35 | # Root of the project relative to this file: src/project_forge/engine/ → ../../.. |
15 | 36 | _PROJECT_ROOT = Path(__file__).parent.parent.parent.parent |
16 | 37 |
|
@@ -174,16 +195,156 @@ def gather_self_context() -> dict: |
174 | 195 | """ |
175 | 196 |
|
176 | 197 |
|
177 | | -def build_introspection_prompt(context: dict, recent_improvements: list[str]) -> str: |
| 198 | +async def gather_generation_signals(db: Database) -> dict: |
| 199 | + """Pull telemetry into a structured dict for the generation-mode prompt. |
| 200 | +
|
| 201 | + Each value is the raw output from engine/telemetry; the prompt builder |
| 202 | + is responsible for formatting. |
| 203 | + """ |
| 204 | + from project_forge.engine import telemetry |
| 205 | + |
| 206 | + return { |
| 207 | + "filter_rate_by_category": await telemetry.filter_rate_by_category(db, days=7), |
| 208 | + "saturation_per_concept": await telemetry.saturation_per_concept(db, days=30, top_n=10), |
| 209 | + "novelty_trend": await telemetry.novelty_trend(db, days=14), |
| 210 | + "diversity_lever_usage": await telemetry.diversity_lever_usage(db, days=7), |
| 211 | + "coverage_gaps": await telemetry.coverage_gaps(db, threshold=20), |
| 212 | + } |
| 213 | + |
| 214 | + |
| 215 | +def _format_generation_signals(signals: dict) -> str: |
| 216 | + lines = [] |
| 217 | + |
| 218 | + rates = signals.get("filter_rate_by_category", {}) |
| 219 | + if rates: |
| 220 | + sorted_rates = sorted(rates.items(), key=lambda x: -x[1]) |
| 221 | + lines.append("### Filter rate by category (last 7d)") |
| 222 | + for cat, rate in sorted_rates: |
| 223 | + cat_val = cat.value if hasattr(cat, "value") else str(cat) |
| 224 | + lines.append(f"- {cat_val}: {rate:.2%}") |
| 225 | + lines.append("") |
| 226 | + |
| 227 | + sat = signals.get("saturation_per_concept", []) |
| 228 | + if sat: |
| 229 | + lines.append("### Saturated concepts (last 30d, top 10)") |
| 230 | + for word, count in sat: |
| 231 | + lines.append(f"- {word}: {count} rejections") |
| 232 | + lines.append("") |
| 233 | + |
| 234 | + trend = signals.get("novelty_trend", []) |
| 235 | + if trend: |
| 236 | + lines.append("### Novelty trend — avg tagline-similarity per day (rising = worse)") |
| 237 | + for day, score in trend[-7:]: |
| 238 | + lines.append(f"- {day}: {score:.3f}") |
| 239 | + lines.append("") |
| 240 | + |
| 241 | + levers = signals.get("diversity_lever_usage", {}) |
| 242 | + if levers: |
| 243 | + lines.append("### Diversity lever usage (last 7d)") |
| 244 | + for lever, pct in levers.items(): |
| 245 | + lines.append(f"- {lever}: {pct:.0%}") |
| 246 | + lines.append("") |
| 247 | + |
| 248 | + gaps = signals.get("coverage_gaps", []) |
| 249 | + if gaps: |
| 250 | + lines.append("### Coverage gaps (categories with <20 active ideas)") |
| 251 | + for cat in gaps: |
| 252 | + cat_val = cat.value if hasattr(cat, "value") else str(cat) |
| 253 | + lines.append(f"- {cat_val}") |
| 254 | + lines.append("") |
| 255 | + |
| 256 | + return "\n".join(lines) if lines else "(no signals yet)" |
| 257 | + |
| 258 | + |
| 259 | +_GENERATION_MODE_PROMPT_TEMPLATE = """\ |
| 260 | +You are analyzing the Project Forge idea-generation engine to propose ONE \ |
| 261 | +surgical patch that improves idea quality. You are NOT proposing a new project. \ |
| 262 | +You are NOT fixing lint or unrelated bugs. You ARE editing the generation \ |
| 263 | +pipeline so the next batch of ideas is better. |
| 264 | +
|
| 265 | +## STRICT RULES |
| 266 | +1. Your patch MUST modify at least one file in: |
| 267 | + - src/project_forge/engine/prompts.py |
| 268 | + - src/project_forge/engine/categories.py |
| 269 | + - src/project_forge/engine/super_ideas.py |
| 270 | + - src/project_forge/engine/router.py |
| 271 | + - src/project_forge/engine/dedup.py |
| 272 | +2. Your market_analysis MUST contain the phrase "Target metric:" followed by \ |
| 273 | + the specific metric you expect to move (e.g. \ |
| 274 | + "Target metric: filter_rate[security-tool] should drop"). |
| 275 | +3. ONE hypothesis per patch. No shotgun changes across unrelated concerns. |
| 276 | +4. Use these files as reference for what's currently saturated/broken — see \ |
| 277 | + the telemetry signals below. |
| 278 | +
|
| 279 | +## Generation Telemetry Signals |
| 280 | +{signals_section} |
| 281 | +
|
| 282 | +## Project File Tree (focus on engine/) |
| 283 | +{file_tree_section} |
| 284 | +
|
| 285 | +## Recently Suggested Improvements (avoid duplicates) |
| 286 | +{recent_improvements_section} |
| 287 | +
|
| 288 | +## Recent Commits |
| 289 | +{commits_section} |
| 290 | +
|
| 291 | +## Your Task |
| 292 | +
|
| 293 | +Propose ONE concrete patch to the generation pipeline. Reference the \ |
| 294 | +saturation, novelty, or coverage signal that motivates it. |
| 295 | +
|
| 296 | +Respond with ONLY valid JSON in this exact format: |
| 297 | +{{ |
| 298 | + "name": "Short Patch Name (2-4 words)", |
| 299 | + "tagline": "What metric moves and why (under 100 chars)", |
| 300 | + "description": "What's broken in the current generation logic, which file(s) to edit, and the specific change", |
| 301 | + "category": "self-improvement", |
| 302 | + "market_analysis": "Target metric: <metric>. Current value: <x>. Expected after patch: <y>. Why.", |
| 303 | + "feasibility_score": 0.85, |
| 304 | + "mvp_scope": "Exact files to change in src/project_forge/engine/ and tests/", |
| 305 | + "tech_stack": ["python", "pytest"], |
| 306 | + "affected_files": ["src/project_forge/engine/prompts.py", "tests/test_prompts.py"] |
| 307 | +}} |
| 308 | +""" |
| 309 | + |
| 310 | + |
| 311 | +def build_introspection_prompt( |
| 312 | + context: dict, |
| 313 | + recent_improvements: list[str], |
| 314 | + *, |
| 315 | + mode: Literal["code-fix", "generation"] = "code-fix", |
| 316 | + generation_signals: dict | None = None, |
| 317 | +) -> str: |
178 | 318 | """Build a prompt string for Claude to suggest one self-improvement idea. |
179 | 319 |
|
180 | 320 | Args: |
181 | 321 | context: Dict returned by gather_self_context(). |
182 | 322 | recent_improvements: Names of recently suggested improvements to avoid duplicates. |
| 323 | + mode: 'code-fix' (default) for the existing lint/test prompt, or |
| 324 | + 'generation' for the surgical idea-quality patch prompt. |
| 325 | + generation_signals: Required when mode='generation'. Output of |
| 326 | + gather_generation_signals(db). |
183 | 327 |
|
184 | 328 | Returns: |
185 | 329 | A formatted prompt string ready to send to Claude. |
186 | 330 | """ |
| 331 | + if mode == "generation": |
| 332 | + if generation_signals is None: |
| 333 | + raise ValueError("mode='generation' requires generation_signals") |
| 334 | + commits = context.get("recent_commits", []) |
| 335 | + commits_section = "\n".join(f"- {c}" for c in commits) if commits else "(none)" |
| 336 | + recent_section = "\n".join(f"- {n}" for n in recent_improvements) if recent_improvements else "(none yet)" |
| 337 | + file_tree = context.get("file_tree", []) |
| 338 | + engine_files = [f for f in file_tree if "engine/" in f] |
| 339 | + file_tree_section = "\n".join(f"- {f}" for f in engine_files) if engine_files else "(not available)" |
| 340 | + return _GENERATION_MODE_PROMPT_TEMPLATE.format( |
| 341 | + signals_section=_format_generation_signals(generation_signals), |
| 342 | + file_tree_section=file_tree_section, |
| 343 | + recent_improvements_section=recent_section, |
| 344 | + commits_section=commits_section, |
| 345 | + ) |
| 346 | + |
| 347 | + # Default: code-fix mode (unchanged) |
187 | 348 | # Issues section |
188 | 349 | issues = context.get("open_issues", []) |
189 | 350 | if issues: |
@@ -260,3 +421,31 @@ def validate_self_improvement(idea) -> bool: |
260 | 421 | return False |
261 | 422 |
|
262 | 423 | return True |
| 424 | + |
| 425 | + |
| 426 | +_TARGET_METRIC_RE = re.compile(r"target\s*metric\s*:", re.IGNORECASE) |
| 427 | + |
| 428 | + |
| 429 | +def validate_generation_patch(idea: Idea) -> bool: |
| 430 | + """Validate a generation-mode SI patch. |
| 431 | +
|
| 432 | + Requirements: |
| 433 | + - market_analysis names a Target metric. |
| 434 | + - description or mvp_scope mentions a file in GENERATION_FILES. |
| 435 | +
|
| 436 | + Returns True if valid; False otherwise (with a logged reason). |
| 437 | + """ |
| 438 | + text = f"{idea.description}\n{idea.mvp_scope}\n{idea.market_analysis}" |
| 439 | + |
| 440 | + if not _TARGET_METRIC_RE.search(idea.market_analysis or ""): |
| 441 | + logger.info("Generation patch '%s' rejected: missing 'Target metric:' declaration", idea.name) |
| 442 | + return False |
| 443 | + |
| 444 | + if not any(path_hint in text for path_hint in GENERATION_FILES): |
| 445 | + logger.info( |
| 446 | + "Generation patch '%s' rejected: no generation file referenced (need one of %s)", |
| 447 | + idea.name, GENERATION_FILES, |
| 448 | + ) |
| 449 | + return False |
| 450 | + |
| 451 | + return True |
0 commit comments