Skip to content

Commit dc41dd3

Browse files
rayketchamclaude
andcommitted
feat: wire filter_summary end-to-end (Direction A live)
Phase 4 plumbing existed but wasn't connected. Now: - engine/telemetry.build_filter_summary(db): composes the dict shape consumed by build_generation_prompt — saturated_concepts (top 5) and high_filter_rate_categories (>=70% threshold, sorted desc) - IdeaGenerator.generate accepts filter_summary kwarg and forwards it to build_generation_prompt - cron/scheduler.generate_and_store calls build_filter_summary on every cycle and passes the result through, so Claude now sees on every generation: "Saturated concepts (avoid): certificate, detection, compliance, migration, chain. High filter-rate categories: nist-standards (80%), security-tool (73%), pqc-cryptography (71%)." End-to-end TDD: 6 new tests, full suite still green. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f538022 commit dc41dd3

4 files changed

Lines changed: 219 additions & 0 deletions

File tree

src/project_forge/cron/scheduler.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ async def generate_and_store(db: Database, generator: IdeaGenerator) -> Idea:
9191
"\n".join(f"- {r.repo_full_name}: {r.description}" for r in repos) if repos else None
9292
)
9393

94+
# Build saturation/filter-rate summary so Claude knows what to avoid (Phase 4 wiring)
95+
from project_forge.engine.telemetry import build_filter_summary
96+
97+
filter_summary = await build_filter_summary(db)
98+
9499
run = GenerationRun(category=category)
95100

96101
try:
@@ -100,6 +105,7 @@ async def generate_and_store(db: Database, generator: IdeaGenerator) -> Idea:
100105
use_contrarian=use_contrarian,
101106
use_combinatoric=use_combinatoric,
102107
portfolio_context=portfolio_context,
108+
filter_summary=filter_summary,
103109
)
104110
result = review_idea(idea)
105111
if not result.passed:

src/project_forge/engine/generator.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ async def generate(
3131
use_combinatoric: bool = False,
3232
prompt_override: str | None = None,
3333
portfolio_context: str | None = None,
34+
*,
35+
filter_summary: dict | None = None,
3436
) -> Idea:
3537
if prompt_override is not None:
3638
prompt = prompt_override
@@ -41,6 +43,7 @@ async def generate(
4143
use_contrarian=use_contrarian,
4244
use_combinatoric=use_combinatoric,
4345
portfolio_context=portfolio_context,
46+
filter_summary=filter_summary,
4447
)
4548

4649
logger.info("Generating idea for category: %s", category.value)

src/project_forge/engine/telemetry.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,35 @@ async def diversity_lever_usage(
147147
return {"contrarian": 0.0, "combinatoric": 0.0, "static": 0.0}
148148

149149

150+
async def build_filter_summary(
151+
db: Database,
152+
*,
153+
top_concepts: int = 5,
154+
rate_threshold: float = 0.7,
155+
days: int = 30,
156+
) -> dict:
157+
"""Compose the filter_summary dict consumed by build_generation_prompt.
158+
159+
Calls saturation_per_concept + filter_rate_by_category and shapes the
160+
result for direct injection into the prompt.
161+
162+
Returns: {"saturated_concepts": list[str],
163+
"high_filter_rate_categories": list[tuple[str, float]]}
164+
"""
165+
sat = await saturation_per_concept(db, days=days, top_n=top_concepts)
166+
rates = await filter_rate_by_category(db, days=min(days, 7))
167+
168+
high = sorted(
169+
((cat.value, r) for cat, r in rates.items() if r >= rate_threshold),
170+
key=lambda x: -x[1],
171+
)
172+
173+
return {
174+
"saturated_concepts": [w for w, _ in sat],
175+
"high_filter_rate_categories": list(high),
176+
}
177+
178+
150179
async def coverage_gaps(
151180
db: Database, threshold: int = 5,
152181
) -> list[IdeaCategory]:
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""TDD: Wire telemetry.build_filter_summary + IdeaGenerator integration.
2+
3+
Phase 4 plumbing was landed; this wires it end-to-end so the cron runner
4+
actually injects the saturation summary into Claude's prompt.
5+
6+
- engine/telemetry.build_filter_summary(db, ...) returns the dict shape
7+
expected by build_generation_prompt(filter_summary=...).
8+
- IdeaGenerator.generate(... filter_summary=...) forwards it to the
9+
prompt builder so cron only needs to build the summary once and pass it in.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from datetime import UTC, datetime, timedelta
15+
16+
import pytest
17+
import pytest_asyncio
18+
19+
from project_forge.models import FilteredIdea, Idea, IdeaCategory
20+
from project_forge.storage.db import Database
21+
22+
23+
def _idea(name: str, *, category: IdeaCategory = IdeaCategory.SECURITY_TOOL) -> Idea:
24+
return Idea(
25+
name=name,
26+
tagline=f"tag for {name}",
27+
description="d",
28+
category=category,
29+
market_analysis="m",
30+
feasibility_score=0.8,
31+
mvp_scope="mvp",
32+
tech_stack=["python"],
33+
)
34+
35+
36+
def _filtered(name: str, *, category: IdeaCategory = IdeaCategory.SECURITY_TOOL,
37+
days_ago: int = 1) -> FilteredIdea:
38+
fi = FilteredIdea(
39+
idea_name=name,
40+
idea_tagline="t",
41+
idea_category=category,
42+
filter_reason="duplicate:tagline_similarity:0.9",
43+
original_idea_json="{}",
44+
)
45+
fi.filtered_at = datetime.now(UTC) - timedelta(days=days_ago)
46+
return fi
47+
48+
49+
@pytest_asyncio.fixture
50+
async def db(tmp_path):
51+
d = Database(tmp_path / "wire_filter.db")
52+
await d.connect()
53+
yield d
54+
await d.close()
55+
56+
57+
# ── build_filter_summary: produces shape consumed by build_generation_prompt
58+
59+
60+
class TestBuildFilterSummary:
61+
@pytest.mark.asyncio
62+
async def test_returns_expected_keys(self, db):
63+
from project_forge.engine.telemetry import build_filter_summary
64+
65+
summary = await build_filter_summary(db)
66+
assert "saturated_concepts" in summary
67+
assert "high_filter_rate_categories" in summary
68+
69+
@pytest.mark.asyncio
70+
async def test_lists_top_saturated_concepts(self, db):
71+
from project_forge.engine.telemetry import build_filter_summary
72+
73+
for name in (
74+
"certificate alpha", "certificate beta", "certificate gamma",
75+
"certificate delta", "certificate epsilon",
76+
):
77+
await db.save_filtered_idea(_filtered(name))
78+
for name in ("detection one", "detection two"):
79+
await db.save_filtered_idea(_filtered(name))
80+
81+
summary = await build_filter_summary(db, top_concepts=2)
82+
# Most-rejected concept first
83+
assert summary["saturated_concepts"][0] == "certificate"
84+
assert len(summary["saturated_concepts"]) <= 2
85+
86+
@pytest.mark.asyncio
87+
async def test_lists_high_filter_rate_categories(self, db):
88+
from project_forge.engine.telemetry import build_filter_summary
89+
90+
# security-tool: 1 accept, 4 reject → 0.80
91+
await db.save_idea(_idea("Accept ST"))
92+
for n in ("R1", "R2", "R3", "R4"):
93+
await db.save_filtered_idea(_filtered(n))
94+
# privacy: 1 accept, 0 reject → 0.00 (well below threshold)
95+
await db.save_idea(_idea("Accept PR", category=IdeaCategory.PRIVACY))
96+
97+
summary = await build_filter_summary(db, rate_threshold=0.5)
98+
cats = [c for c, _ in summary["high_filter_rate_categories"]]
99+
assert "security-tool" in cats
100+
assert "privacy" not in cats
101+
102+
@pytest.mark.asyncio
103+
async def test_empty_db_returns_empty_lists(self, db):
104+
from project_forge.engine.telemetry import build_filter_summary
105+
106+
summary = await build_filter_summary(db)
107+
assert summary["saturated_concepts"] == []
108+
assert summary["high_filter_rate_categories"] == []
109+
110+
@pytest.mark.asyncio
111+
async def test_categories_sorted_by_rate_descending(self, db):
112+
from project_forge.engine.telemetry import build_filter_summary
113+
114+
# security-tool: 4 of 5 = 0.80
115+
await db.save_idea(_idea("Accept ST"))
116+
for n in ("R1", "R2", "R3", "R4"):
117+
await db.save_filtered_idea(_filtered(n, category=IdeaCategory.SECURITY_TOOL))
118+
# automation: 7 of 10 = 0.70
119+
for i in range(3):
120+
await db.save_idea(_idea(f"Acc auto {i}", category=IdeaCategory.AUTOMATION))
121+
for i in range(7):
122+
await db.save_filtered_idea(_filtered(f"Rej auto {i}", category=IdeaCategory.AUTOMATION))
123+
124+
summary = await build_filter_summary(db, rate_threshold=0.6)
125+
rates = summary["high_filter_rate_categories"]
126+
# First entry must have higher rate than second
127+
assert len(rates) >= 2
128+
assert rates[0][1] >= rates[1][1]
129+
130+
131+
# ── IdeaGenerator forwards filter_summary to build_generation_prompt
132+
133+
134+
class TestIdeaGeneratorFilterSummaryForwarding:
135+
def test_generate_accepts_filter_summary_kwarg(self, monkeypatch):
136+
"""IdeaGenerator.generate must accept and forward filter_summary."""
137+
import asyncio
138+
139+
from project_forge.engine.generator import IdeaGenerator
140+
141+
captured = {}
142+
143+
_FAKE_JSON = (
144+
'{"name":"X","tagline":"t","description":"d","category":"security-tool",'
145+
'"market_analysis":"m","feasibility_score":0.8,"mvp_scope":"mvp",'
146+
'"tech_stack":["py"]}'
147+
)
148+
149+
# Stub anthropic + the prompt builder so we observe what was passed
150+
class _StubMessages:
151+
def create(self, **kwargs): # noqa: ARG002
152+
class _Resp:
153+
content = [type("X", (), {"text": _FAKE_JSON})]
154+
return _Resp()
155+
156+
class _StubClient:
157+
messages = _StubMessages()
158+
159+
def stub_build_prompt(**kwargs):
160+
captured.update(kwargs)
161+
return "PROMPT"
162+
163+
gen = IdeaGenerator.__new__(IdeaGenerator)
164+
gen.client = _StubClient()
165+
gen.model = "stub-model"
166+
monkeypatch.setattr(
167+
"project_forge.engine.generator.build_generation_prompt",
168+
stub_build_prompt,
169+
)
170+
171+
summary = {
172+
"saturated_concepts": ["certificate"],
173+
"high_filter_rate_categories": [("security-tool", 0.80)],
174+
}
175+
176+
asyncio.run(gen.generate(
177+
category=IdeaCategory.SECURITY_TOOL,
178+
filter_summary=summary,
179+
))
180+
181+
assert captured.get("filter_summary") == summary

0 commit comments

Comments
 (0)