Skip to content

Commit aaec239

Browse files
rayketchamclaude
andcommitted
feat: SI generation mode — surgical idea-quality patches (#55)
Phase 2 of self-improvement modernization. SI gains a second mode in addition to its existing code-fix loop: 'generation' mode patches the idea-generation pipeline itself. - introspect.gather_generation_signals(db): pulls all telemetry into one structured dict for the LLM to reason over - build_introspection_prompt(..., mode='generation', generation_signals=…): emits a hard-constrained prompt requiring ONE patch that touches only generation files (engine/{prompts,categories,super_ideas,router,dedup}.py) and declares 'Target metric: <X> should move' - validate_generation_patch(idea): rejects patches missing Target metric declaration or touching out-of-scope files - code-fix mode unchanged (backward compatible — no kwargs == old behavior) Closes #55. Next: shadow validation gate (#56) so SI cannot ship a patch that fails to actually move its declared metric. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1777067 commit aaec239

2 files changed

Lines changed: 403 additions & 1 deletion

File tree

src/project_forge/engine/introspect.py

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,36 @@
22
33
Gathers context about the project's own codebase, tests, and open issues,
44
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.
510
"""
611

12+
from __future__ import annotations
13+
714
import json
815
import logging
16+
import re
917
import subprocess
1018
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
1124

1225
logger = logging.getLogger(__name__)
1326

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+
1435
# Root of the project relative to this file: src/project_forge/engine/ → ../../..
1536
_PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
1637

@@ -174,16 +195,156 @@ def gather_self_context() -> dict:
174195
"""
175196

176197

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:
178318
"""Build a prompt string for Claude to suggest one self-improvement idea.
179319
180320
Args:
181321
context: Dict returned by gather_self_context().
182322
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).
183327
184328
Returns:
185329
A formatted prompt string ready to send to Claude.
186330
"""
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)
187348
# Issues section
188349
issues = context.get("open_issues", [])
189350
if issues:
@@ -260,3 +421,31 @@ def validate_self_improvement(idea) -> bool:
260421
return False
261422

262423
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

Comments
 (0)