VentureForge / src /agents /idea_generator.py
Raiquia's picture
Upload 103 files
a6e70b1 verified
Raw
History Blame Contribute Delete
17.4 kB
"""Idea Generator — clusters pain points into themes and generates distinct
startup ideas.
Pipeline flow
=============
1. Receive filtered pain points from state.
2. Cap to a manageable number (~50) to fit context window.
3. LLM brainstorms ideas grouped by themes, returns structured JSON.
4. Code validates that every ``addresses_pain_point_ids`` references a real
pain point UUID that was present in the input.
5. Return validated Idea objects.
"""
from __future__ import annotations
import json
import logging
import time
from uuid import UUID, uuid4
from langchain_core.messages import HumanMessage, SystemMessage
from src.llm.client import extract_json, get_llm
from src.llm.prompts import get_prompt
from src.state.schema import Idea, PipelineStage, VentureForgeState
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Tunables
# ------------------------------------------------------------------
_MAX_PAIN_POINTS_CONTEXT: int = 50 # avoid blowing context window
_IDEAS_PER_RUN_DEFAULT: int = 5
def _build_system_prompt() -> str:
return get_prompt("idea_generator")
def _build_user_prompt(state: VentureForgeState) -> str:
# Sort pain points by evidence count (descending) to prioritize well-validated pain points
# Then cap to context window limit
sorted_pps = sorted(
state.filtered_pain_points,
key=lambda pp: len(pp.evidence),
reverse=True
)
pps = sorted_pps[:_MAX_PAIN_POINTS_CONTEXT]
domain = state.domain
count = state.ideas_per_run or _IDEAS_PER_RUN_DEFAULT
feedback = state.revision_feedback or "None"
# Serialize pain points with full evidence array
pp_blobs: list[dict] = [
{
"id": str(pp.id),
"title": pp.title,
"description": pp.description,
"evidence": [
{
"source_url": ev.source_url,
"raw_quote": ev.raw_quote,
"source": ev.source.value,
}
for ev in pp.evidence
],
"evidence_count": len(pp.evidence),
}
for pp in pps
]
# If revision feedback exists, this run is part of the reflection
# loop (typically triggered by the Critic for positioning issues
# such as target_is_contained_fire or competition_embraced_with_thesis).
# Make that explicit in the prompt so the LLM focuses on fixing
# those weaknesses first.
revision_block = ""
if state.revision_feedback:
revision_block = (
"THIS IS A REVISION ROUND. The critic flagged weaknesses in "
"positioning (e.g., target user not a contained community, "
"or weak competitive thesis). You MUST address the following "
"feedback before generating ideas:\n" # noqa: E501
f"- Critic feedback: {feedback}\n\n"
"In your new ideas, make the target_user a specific, named, "
"reachable community (a 'contained fire') and make the "
"competition thesis explicit: what are users doing today and "
"what incumbents are afraid to do.\n\n"
)
# Determine minimum pain point references based on availability
# If only 1 pain point exists, require 1; otherwise require 2
min_refs = min(2, len(pps))
# Build adaptive requirement block based on available pain points
if len(pps) == 1:
requirement_block = (
"**SPECIAL CASE: Only 1 pain point available.**\\n"
"Generate ideas that deeply address this single pain point. "
"Each idea must reference this pain point UUID in 'addresses_pain_point_ids'. "
"Focus on different solution angles, user segments, or implementation approaches for variety.\\n\\n"
)
elif len(pps) >= 2:
requirement_block = (
f"**CRITICAL REQUIREMENT: Each idea MUST reference AT LEAST {min_refs} pain point UUIDs in 'addresses_pain_point_ids'.**\\n"
f"Ideas with fewer than {min_refs} references will be REJECTED. "
"Cross-pollinate pain points to create stronger, more defensible ideas that solve multiple problems.\\n\\n"
)
else:
requirement_block = "ERROR: No pain points provided. Cannot generate ideas.\\n\\n"
user_text = (
f"Domain: {domain}\\n"
f"Ideas to generate: {count}\\n\\n"
f"PAIN POINTS ({len(pps)} provided):\\n"
f"{json.dumps(pp_blobs, indent=2)}\\n\\n"
f"{revision_block}"
f"{requirement_block}"
"Only use UUIDs from the pain points list above — do not invent new UUIDs.\\n\\n"
"Return JSON: {\\\"ideas\\\": [ ... ]}."
)
return user_text
def _build_user_prompt_single(state: VentureForgeState, idea_number: int, total_ideas: int) -> str:
"""Build prompt for generating a SINGLE idea to reduce token usage.
Generates one idea at a time for:
- Better fit within vLLM 2048 token limit
- More focused, comprehensive ideas
- LLM can concentrate on each idea individually
Args:
state: Current pipeline state
idea_number: Which idea this is (1-indexed for display)
total_ideas: Total number of ideas to generate
"""
# Sort pain points by evidence count
sorted_pps = sorted(
state.filtered_pain_points,
key=lambda pp: len(pp.evidence),
reverse=True
)
pps = sorted_pps[:_MAX_PAIN_POINTS_CONTEXT]
domain = state.domain
feedback = state.revision_feedback or "None"
# Serialize pain points with evidence (limit to top 2 evidence items per pain point)
pp_blobs: list[dict] = [
{
"id": str(pp.id),
"title": pp.title,
"description": pp.description,
"evidence": [
{
"source_url": ev.source_url,
"raw_quote": ev.raw_quote[:300], # Truncate long quotes
"source": ev.source.value,
}
for ev in pp.evidence[:2] # Only top 2 evidence items
],
"evidence_count": len(pp.evidence),
}
for pp in pps
]
# Revision block if applicable
revision_block = ""
if state.revision_feedback:
revision_block = (
"THIS IS A REVISION ROUND. The critic flagged weaknesses in positioning. "
"You MUST address the following feedback:\n"
f"- Critic feedback: {feedback}\n\n"
"Make the target_user a specific, named, reachable community (a 'contained fire') "
"and make the competition thesis explicit.\n\n"
)
# Determine minimum pain point references
min_refs = min(2, len(pps))
# Build requirement block
if len(pps) == 1:
requirement_block = (
"**SPECIAL CASE: Only 1 pain point available.**\n"
"Generate an idea that deeply addresses this single pain point. "
"The idea must reference this pain point UUID in 'addresses_pain_point_ids'.\n\n"
)
elif len(pps) >= 2:
requirement_block = (
f"**CRITICAL: The idea MUST reference AT LEAST {min_refs} pain point UUIDs in 'addresses_pain_point_ids'.**\n"
f"Ideas with fewer than {min_refs} references will be REJECTED. "
"Cross-pollinate pain points to create a stronger, more defensible idea.\n\n"
)
else:
requirement_block = "ERROR: No pain points provided.\n\n"
user_text = (
f"Domain: {domain}\n"
f"Generating idea {idea_number} of {total_ideas}\n\n"
f"PAIN POINTS ({len(pps)} provided):\n"
f"{json.dumps(pp_blobs, indent=2)}\n\n"
f"{revision_block}"
f"{requirement_block}"
"Only use UUIDs from the pain points list above — do not invent new UUIDs.\n\n"
"Return a single JSON object (not an array): {\"title\": ..., \"one_liner\": ..., ...}"
)
return user_text
def _invoke_llm_single(state: VentureForgeState, idea_number: int, total_ideas: int, retry_count: int = 0) -> dict | None:
"""Invoke LLM to generate a SINGLE idea.
Args:
state: Current pipeline state
idea_number: Which idea this is (1-indexed)
total_ideas: Total number of ideas to generate
retry_count: Current retry attempt (0-indexed)
Returns:
Raw idea dict, or None on failure
"""
llm = get_llm(temperature=0.7, max_tokens=16384, reasoning=False)
system_prompt = _build_system_prompt()
system_prompt += "\n\n**CRITICAL: Output ONLY a single JSON object. No markdown fences, no explanations. Start with { and end with }.**"
messages = [
SystemMessage(content=system_prompt),
HumanMessage(content=_build_user_prompt_single(state, idea_number, total_ideas)),
]
start = time.monotonic()
try:
raw = llm.invoke(messages)
content = raw.content if hasattr(raw, "content") else str(raw)
except Exception as e:
logger.error(f"[idea_generator] LLM invocation failed for idea {idea_number} (attempt {retry_count + 1}): {e}")
return None
elapsed = time.monotonic() - start
logger.info(f"[idea_generator] LLM responded in {elapsed:.1f}s for idea {idea_number} (attempt {retry_count + 1})")
# Warn if response looks truncated
if content and not content.rstrip().endswith('}'):
logger.warning(
f"[idea_generator] Response may be truncated for idea {idea_number}. "
f"Last 100 chars: {content[-100:]}"
)
parsed = extract_json(content)
if parsed is None:
logger.error(
f"[idea_generator] JSON extraction failed for idea {idea_number} (attempt {retry_count + 1}). "
f"Response length: {len(content)} chars"
)
logger.error(f"[idea_generator] Response preview: {content[:500]}")
return None
# Handle both dict and wrapped dict formats
if isinstance(parsed, dict):
if "ideas" in parsed and isinstance(parsed["ideas"], list):
return parsed["ideas"][0] if parsed["ideas"] else None
return parsed
return None
def _invoke_llm(state: VentureForgeState) -> list[dict]:
"""Call LLM, parse JSON, return raw idea dicts."""
llm = get_llm(temperature=0.7, max_tokens=16384, reasoning=False)
# Add explicit JSON-only instruction
system_prompt = _build_system_prompt()
system_prompt += "\n\n**CRITICAL: Output ONLY the JSON object. No markdown code fences, no explanations, no preamble. Start with { and end with }.**"
messages = [
SystemMessage(content=system_prompt),
HumanMessage(content=_build_user_prompt(state)),
]
start = time.monotonic()
try:
raw = llm.invoke(messages)
content = raw.content if hasattr(raw, "content") else str(raw)
except Exception as e:
logger.error(f"[idea_generator] LLM invocation failed after {time.monotonic()-start:.1f}s: {e}")
return []
logger.info(f"[idea_generator] LLM responded in {time.monotonic()-start:.1f}s")
# Debug: log response preview
logger.info(f"[idea_generator] Response preview (first 500 chars): {content[:500]}")
parsed = extract_json(content)
if parsed is None:
logger.error(f"[idea_generator] JSON extraction failed. Response length: {len(content)} chars")
logger.error(f"[idea_generator] Full response (first 2000 chars): {content[:2000]}")
return []
ideas = parsed.get("ideas") if isinstance(parsed, dict) else parsed if isinstance(parsed, list) else []
if not isinstance(ideas, list):
logger.warning("[idea_generator] LLM did not return an array of ideas")
return []
return ideas
def _validate_idea(raw: dict, valid_ids: set[UUID], min_refs: int = 2) -> Idea | None:
"""Return an Idea if it references real pain points, else None.
Args:
raw: Raw idea dict from LLM
valid_ids: Set of valid pain point UUIDs
min_refs: Minimum number of pain point references required (default 2)
"""
# Parse UUID references
raw_ids = raw.get("addresses_pain_point_ids", [])
resolved: list[UUID] = []
for rid in raw_ids:
try:
uid = UUID(str(rid))
if uid in valid_ids:
resolved.append(uid)
except (ValueError, TypeError):
continue
if len(resolved) < min_refs:
logger.debug(
f"[idea_generator] REJECTED — idea '{raw.get('title', '?')}' "
f"references only {len(resolved)} valid pain point(s), need {min_refs}"
)
return None
try:
idea = Idea(
id=uuid4(),
title=raw["title"],
one_liner=raw["one_liner"],
problem=raw["problem"],
solution=raw["solution"],
target_user=raw["target_user"],
key_features=raw.get("key_features", []),
addresses_pain_point_ids=resolved,
)
return idea
except Exception as e:
logger.debug(f"[idea_generator] REJECTED — malformed idea: {e}")
return None
# ------------------------------------------------------------------
# Main entry point
# ------------------------------------------------------------------
def run(state: VentureForgeState) -> dict:
pps = state.filtered_pain_points
if not pps:
logger.warning("[idea_generator] no pain points available — returning empty")
patch = {
"ideas": [],
"current_stage": PipelineStage.GENERATING,
"next_node": "orchestrator",
"idea_generation_attempts": state.idea_generation_attempts + 1,
}
patch.update(
state.add_event(
agent="idea_generator",
stage=PipelineStage.GENERATING,
kind="warning",
message="No pain points available to generate ideas from.",
)
)
return patch
valid_ids = {pp.id for pp in pps}
min_refs = min(2, len(pps)) # Adaptive: require 1 ref if only 1 pain point exists
# ONE-IDEA-AT-A-TIME GENERATION
# Determine how many ideas to generate
if state.current_revision_idea_id:
count = 1
logger.info(f"[idea_generator] Revision mode: generating 1 replacement idea for {state.current_revision_idea_id}")
else:
count = state.ideas_per_run or _IDEAS_PER_RUN_DEFAULT
logger.info(f"[idea_generator] Initial generation: generating {count} ideas one at a time")
MAX_RETRIES = 3
raw_ideas = []
# Generate one idea at a time
for i in range(count):
idea_number = i + 1
logger.info(f"[idea_generator] Generating idea {idea_number} of {count}")
raw_idea = None
for retry in range(MAX_RETRIES):
raw_idea = _invoke_llm_single(state, idea_number, count, retry_count=retry)
if raw_idea:
logger.info(f"[idea_generator] Successfully generated idea {idea_number} on attempt {retry + 1}")
raw_ideas.append(raw_idea)
break
if retry < MAX_RETRIES - 1:
logger.warning(
f"[idea_generator] Attempt {retry + 1}/{MAX_RETRIES} failed for idea {idea_number}. Retrying..."
)
else:
logger.error(
f"[idea_generator] All {MAX_RETRIES} attempts failed for idea {idea_number}."
)
logger.info(f"[idea_generator] LLM produced {len(raw_ideas)} raw ideas")
# DEBUG: Log first raw idea to diagnose validation failures
if raw_ideas:
logger.info(f"[idea_generator] Sample raw idea: {json.dumps(raw_ideas[0], indent=2)}")
logger.info(f"[idea_generator] Valid pain point IDs: {[str(vid) for vid in list(valid_ids)[:3]]}")
validated: list[Idea] = []
for raw in raw_ideas:
idea = _validate_idea(raw, valid_ids, min_refs)
if idea:
validated.append(idea)
final = validated[:count]
logger.info(
f"[idea_generator] {len(final)}/{len(raw_ideas)} ideas validated "
f"(required <= {count} with >={min_refs} real pain point refs each)"
)
# Merge with existing ideas (if not in revision mode for this specific idea)
if state.current_revision_idea_id:
# In revision mode: add the new idea(s) to existing ideas
all_ideas = state.ideas + final
else:
# Initial generation: replace all ideas
all_ideas = final
patch = {
"ideas": all_ideas,
"idea_generation_attempts": state.idea_generation_attempts + 1,
"current_revision_idea_id": None, # Clear revision flag
"next_node": "orchestrator",
}
patch.update(
state.add_event(
agent="idea_generator",
stage=PipelineStage.GENERATING,
kind="info",
message=(
f"Generated {len(final)} ideas (requested {count}) "
f"addressing ≥{min_refs} validated pain points each."
),
)
)
return patch