| """Orchestrator — routes tasks, manages state, handles reflection loop. Never generates content.""" |
| from __future__ import annotations |
|
|
| import logging |
| import time |
|
|
| from src.state.schema import PipelineStage, VentureForgeState |
|
|
| |
| from src.agents.pain_point_miner import run as run_pain_point_miner |
| from src.agents.idea_generator import run as run_idea_generator |
| from src.agents.scorer import run as run_scorer |
| from src.agents.pitch_writer import run as run_pitch_writer |
| from src.agents.critic import run as run_critic |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| def orchestrator(state: VentureForgeState) -> dict: |
| """ |
| Supervisor node. Based on pipeline progress, decides which worker to run next. |
| Returns a dict patch for state update. |
| """ |
| logger.info( |
| f"[orchestrator] Called with: ideas={len(state.ideas)}, scored={len(state.scored_ideas)}, " |
| f"briefs={len(state.pitch_briefs)}, attempts={state.idea_generation_attempts}/{state.max_idea_generation_attempts}" |
| ) |
| stage = state.current_stage |
|
|
| |
| if not state.pain_points: |
| |
| MAX_INITIAL_MINING_ATTEMPTS = 5 |
| if state.pain_point_miner_revision_count >= MAX_INITIAL_MINING_ATTEMPTS: |
| error_msg = ( |
| f"Reached max initial mining attempts ({MAX_INITIAL_MINING_ATTEMPTS}) with 0 pain points. " |
| f"This usually means: (1) LLM is failing to extract pain points from scraped content, " |
| f"(2) All extracted pain points are failing validation (no verbatim quotes), or " |
| f"(3) Domain '{state.domain}' has insufficient community discussion. " |
| f"Try a different domain or check LLM logs for extraction failures." |
| ) |
| patch = state.mark_failed(error_msg) |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.FAILED, |
| kind="error", |
| message=error_msg, |
| ) |
| ) |
| return patch |
| |
| patch = { |
| "current_stage": PipelineStage.MINING, |
| "next_node": "pain_point_miner", |
| "pain_point_miner_revision_count": state.pain_point_miner_revision_count + 1, |
| } |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.MINING, |
| kind="info", |
| message=f"Routing to pain_point_miner (no pain points yet, attempt {state.pain_point_miner_revision_count + 1}/{MAX_INITIAL_MINING_ATTEMPTS}).", |
| ) |
| ) |
| return patch |
|
|
| |
| MIN_PAIN_POINTS_FOR_IDEAS = 2 |
| MAX_MINING_RETRIES = 2 |
| |
| if not state.ideas: |
| |
| if len(state.filtered_pain_points) < MIN_PAIN_POINTS_FOR_IDEAS: |
| if state.pain_point_miner_revision_count < MAX_MINING_RETRIES: |
| |
| patch = { |
| "current_stage": PipelineStage.MINING, |
| "next_node": "pain_point_miner", |
| "pain_point_miner_revision_count": state.pain_point_miner_revision_count + 1, |
| } |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.MINING, |
| kind="warning", |
| message=( |
| f"Only {len(state.filtered_pain_points)} pain points found " |
| f"(target: {MIN_PAIN_POINTS_FOR_IDEAS}). Retrying mining " |
| f"(attempt {state.pain_point_miner_revision_count + 1}/{MAX_MINING_RETRIES})." |
| ), |
| ) |
| ) |
| return patch |
| else: |
| |
| patch = { |
| "current_stage": PipelineStage.GENERATING, |
| "next_node": "idea_generator", |
| } |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.GENERATING, |
| kind="warning", |
| message=( |
| f"Proceeding with only {len(state.filtered_pain_points)} pain points " |
| f"after {MAX_MINING_RETRIES} mining attempts. Idea quality may be lower." |
| ), |
| ) |
| ) |
| return patch |
| |
| |
| if state.idea_generation_attempts >= state.max_total_llm_calls_per_agent: |
| error_msg = ( |
| f"Reached global LLM call limit ({state.max_total_llm_calls_per_agent}) for idea_generator. " |
| f"This prevents excessive retries from validation failures + Critic revisions. " |
| f"Check logs for root cause (invalid pain_point_ids, schema mismatches, etc.)." |
| ) |
| patch = state.mark_failed(error_msg) |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.FAILED, |
| kind="error", |
| message=error_msg, |
| ) |
| ) |
| return patch |
| |
| |
| if state.idea_generation_attempts >= state.max_idea_generation_attempts: |
| error_msg = ( |
| f"Failed to generate valid ideas after {state.idea_generation_attempts} attempts. " |
| "This usually means the LLM is not producing ideas with valid pain_point_ids. " |
| "Check logs for validation failures." |
| ) |
| patch = state.mark_failed(error_msg) |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.FAILED, |
| kind="error", |
| message=error_msg, |
| ) |
| ) |
| return patch |
| |
| patch = { |
| "current_stage": PipelineStage.GENERATING, |
| "next_node": "idea_generator", |
| } |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.GENERATING, |
| kind="info", |
| message=f"Routing to idea_generator (no ideas yet, attempt {state.idea_generation_attempts + 1}/{state.max_idea_generation_attempts}, global {state.idea_generation_attempts + 1}/{state.max_total_llm_calls_per_agent}).", |
| ) |
| ) |
| return patch |
|
|
| if not state.scored_ideas: |
| |
| if state.scorer_attempts >= state.max_total_llm_calls_per_agent: |
| error_msg = ( |
| f"Reached global LLM call limit ({state.max_total_llm_calls_per_agent}) for scorer. " |
| f"This usually means the LLM is failing to generate valid JSON (truncation or parsing errors). " |
| f"Check logs for 'JSON extraction failed' or 'Response may be truncated' warnings." |
| ) |
| patch = state.mark_failed(error_msg) |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.FAILED, |
| kind="error", |
| message=error_msg, |
| ) |
| ) |
| return patch |
| |
| patch = {"current_stage": PipelineStage.SCORING, "next_node": "scorer"} |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.SCORING, |
| kind="info", |
| message=f"Routing to scorer (no scored ideas yet, attempt {state.scorer_attempts + 1}/{state.max_total_llm_calls_per_agent}).", |
| ) |
| ) |
| return patch |
|
|
| if not state.pitch_briefs: |
| |
| MIN_IDEAS_THRESHOLD = max(2, state.ideas_per_run // 2) |
| if len(state.ideas) < MIN_IDEAS_THRESHOLD: |
| |
| logger.info( |
| f"[orchestrator] Insufficient ideas check: {len(state.ideas)} < {MIN_IDEAS_THRESHOLD}. " |
| f"Attempts: {state.idea_generation_attempts}/{state.max_idea_generation_attempts}" |
| ) |
| |
| if state.idea_generation_attempts >= state.max_idea_generation_attempts: |
| |
| error_msg = ( |
| f"Failed to generate sufficient ideas after {state.idea_generation_attempts} attempts. " |
| f"Only {len(state.ideas)} ideas generated (minimum: {MIN_IDEAS_THRESHOLD}). " |
| "This usually means the LLM is not producing ideas with valid pain_point_ids. " |
| "Check logs for validation failures." |
| ) |
| logger.error(f"[orchestrator] Circuit breaker triggered: {error_msg}") |
| patch = state.mark_failed(error_msg) |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.FAILED, |
| kind="error", |
| message=error_msg, |
| ) |
| ) |
| return patch |
| |
| if state.idea_generation_attempts < state.max_idea_generation_attempts: |
| |
| logger.info( |
| f"[orchestrator] Retrying idea generation (attempt {state.idea_generation_attempts + 1}/{state.max_idea_generation_attempts})" |
| ) |
| patch = { |
| "current_stage": PipelineStage.GENERATING, |
| "next_node": "idea_generator", |
| } |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.GENERATING, |
| kind="warning", |
| message=( |
| f"Only {len(state.ideas)} ideas generated (target: {state.ideas_per_run}, " |
| f"minimum: {MIN_IDEAS_THRESHOLD}). Retrying idea generation " |
| f"(attempt {state.idea_generation_attempts + 1}/{state.max_idea_generation_attempts})." |
| ), |
| ) |
| ) |
| return patch |
| |
| |
| scored_idea_ids = {s.idea_id for s in state.scored_ideas} |
| unscored_ideas = [idea for idea in state.ideas if idea.id not in scored_idea_ids] |
| if unscored_ideas: |
| |
| patch = {"current_stage": PipelineStage.SCORING, "next_node": "scorer"} |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.SCORING, |
| kind="info", |
| message=f"Found {len(unscored_ideas)} unscored ideas (after retry). Routing to scorer.", |
| ) |
| ) |
| return patch |
| |
| |
| if state.top_scored_ideas: |
| verdict_counts = { |
| "pursue": sum(1 for s in state.top_scored_ideas if s.verdict == "pursue"), |
| "explore": sum(1 for s in state.top_scored_ideas if s.verdict == "explore"), |
| "park": sum(1 for s in state.top_scored_ideas if s.verdict == "park"), |
| } |
| logger.info( |
| f"[orchestrator] Top {len(state.top_scored_ideas)} ideas verdict distribution: " |
| f"pursue={verdict_counts['pursue']}, explore={verdict_counts['explore']}, park={verdict_counts['park']}" |
| ) |
| |
| |
| |
| if all(s.verdict == "park" for s in state.top_scored_ideas): |
| patch = {} |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.WRITING, |
| kind="warning", |
| message=( |
| f"All {len(state.top_scored_ideas)} top-scored ideas received 'park' verdict " |
| f"(interesting but has concerns). Generating pitch briefs for documentation. " |
| f"Consider: (1) adjusting domain for better pain points, " |
| f"(2) increasing ideas_per_run for more candidates, or " |
| f"(3) reviewing scorer rubric if verdicts seem too harsh." |
| ), |
| ) |
| ) |
| |
| |
| |
| if state.pitch_writer_attempts >= state.max_total_llm_calls_per_agent: |
| error_msg = ( |
| f"Reached global LLM call limit ({state.max_total_llm_calls_per_agent}) for pitch_writer. " |
| f"This usually means the LLM is failing to generate valid JSON (truncation or parsing errors). " |
| f"Check logs for 'JSON extraction failed' or 'Response may be truncated' warnings." |
| ) |
| patch = state.mark_failed(error_msg) |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.FAILED, |
| kind="error", |
| message=error_msg, |
| ) |
| ) |
| return patch |
| |
| patch = {"current_stage": PipelineStage.WRITING, "next_node": "pitch_writer"} |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.WRITING, |
| kind="info", |
| message=f"Routing to pitch_writer (no pitch briefs yet, attempt {state.pitch_writer_attempts + 1}/{state.max_total_llm_calls_per_agent}).", |
| ) |
| ) |
| return patch |
|
|
| |
| if state.critique is None: |
| |
| top_ids = {s.idea_id for s in state.top_scored_ideas} |
| brief_ids = {b.idea_id for b in state.pitch_briefs} |
| |
| if not brief_ids.issubset(top_ids): |
| error_msg = ( |
| f"Pitch briefs contain ideas not in top_scored_ideas. " |
| f"Brief IDs: {brief_ids}, Top IDs: {top_ids}. " |
| f"This indicates a bug in pitch_writer or scorer." |
| ) |
| patch = state.mark_failed(error_msg) |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.FAILED, |
| kind="error", |
| message=error_msg, |
| ) |
| ) |
| return patch |
| |
| |
| patch = {"current_stage": PipelineStage.CRITIQUING, "next_node": "critic"} |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.CRITIQUING, |
| kind="info", |
| message=f"Routing to critic (reviewing brief {state.current_critique_index + 1}/{len(state.pitch_briefs)}).", |
| ) |
| ) |
| return patch |
|
|
| |
| if not state.critique.all_pass and state.can_revise: |
| |
| target = state.critique.target_agent |
| |
| |
| if target == "idea_generator": |
| if state.idea_generation_attempts >= state.max_total_llm_calls_per_agent: |
| error_msg = ( |
| f"Reached global LLM call limit ({state.max_total_llm_calls_per_agent}) " |
| f"for idea_generator during revision loop. " |
| f"Check logs for validation failures or infinite revision loops." |
| ) |
| patch = state.mark_failed(error_msg) |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.FAILED, |
| kind="error", |
| message=error_msg, |
| ) |
| ) |
| return patch |
| |
| elif target == "pitch_writer": |
| if state.pitch_writer_attempts >= state.max_total_llm_calls_per_agent: |
| error_msg = ( |
| f"Reached global LLM call limit ({state.max_total_llm_calls_per_agent}) " |
| f"for pitch_writer during revision loop. " |
| f"Check logs for JSON parsing failures or infinite revision loops." |
| ) |
| patch = state.mark_failed(error_msg) |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.FAILED, |
| kind="error", |
| message=error_msg, |
| ) |
| ) |
| return patch |
| |
| |
| patch = state.bump_revision(state.critique) |
| patch.update(state.reset_for_revision(target, state.critique.idea_id)) |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.REVISING, |
| kind="info", |
| message=( |
| f"Revision requested by critic for idea {state.critique.idea_id} " |
| f"→ target_agent={target}." |
| ), |
| idea_id=state.critique.idea_id, |
| ) |
| ) |
| return patch |
| |
| |
| |
| |
| |
| scored_idea_ids = {s.idea_id for s in state.scored_ideas} |
| unscored_ideas = [idea for idea in state.ideas if idea.id not in scored_idea_ids] |
| |
| if unscored_ideas: |
| logger.info( |
| f"[orchestrator] Found {len(unscored_ideas)} unscored ideas after revision. " |
| f"Routing to scorer before returning to critic." |
| ) |
| patch = {"current_stage": PipelineStage.SCORING, "next_node": "scorer"} |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.SCORING, |
| kind="info", |
| message=f"Found {len(unscored_ideas)} unscored ideas after revision. Routing to scorer.", |
| ) |
| ) |
| return patch |
| |
| |
| if state.top_scored_ideas: |
| top_ids = {s.idea_id for s in state.top_scored_ideas} |
| brief_ids = {b.idea_id for b in state.pitch_briefs} |
| missing_brief_ids = top_ids - brief_ids |
| |
| if missing_brief_ids: |
| logger.info( |
| f"[orchestrator] Found {len(missing_brief_ids)} ideas without pitch briefs after revision. " |
| f"Routing to pitch_writer before returning to critic." |
| ) |
| patch = {"current_stage": PipelineStage.WRITING, "next_node": "pitch_writer"} |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.WRITING, |
| kind="info", |
| message=f"Found {len(missing_brief_ids)} ideas without pitch briefs after revision. Routing to pitch_writer.", |
| ) |
| ) |
| return patch |
| |
| |
| |
| |
| |
| if state.current_critique_index + 1 < len(state.pitch_briefs): |
| |
| patch = { |
| "current_critique_index": state.current_critique_index + 1, |
| "critique": None, |
| "revision_feedback": None, |
| "current_stage": PipelineStage.CRITIQUING, |
| "next_node": "critic", |
| } |
| |
| |
| if state.critique.approval_status == "max_revisions_reached": |
| message = ( |
| f"Brief {state.current_critique_index + 1} reached max revisions " |
| f"(still has {len(state.critique.failing_checks)} failing checks). " |
| f"Moving to brief {state.current_critique_index + 2}/{len(state.pitch_briefs)}." |
| ) |
| else: |
| message = f"Brief {state.current_critique_index + 1} approved. Moving to brief {state.current_critique_index + 2}/{len(state.pitch_briefs)}." |
| |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.CRITIQUING, |
| kind="warning" if state.critique.approval_status == "max_revisions_reached" else "info", |
| message=message, |
| ) |
| ) |
| return patch |
|
|
| |
| |
| max_revision_briefs = [ |
| c for c in state.critiques |
| if c.approval_status == "max_revisions_reached" |
| ] |
| |
| if max_revision_briefs: |
| summary = ( |
| f"Pipeline completed with {len(state.pain_points)} pain points, " |
| f"{len(state.ideas)} ideas, {len(state.scored_ideas)} scored ideas, " |
| f"and {len(state.pitch_briefs)} pitch briefs. " |
| f"⚠️ WARNING: {len(max_revision_briefs)} brief(s) reached max revisions with unresolved quality issues." |
| ) |
| kind = "warning" |
| else: |
| summary = ( |
| f"Pipeline completed with {len(state.pain_points)} pain points, " |
| f"{len(state.ideas)} ideas, {len(state.scored_ideas)} scored ideas, " |
| f"and {len(state.pitch_briefs)} pitch briefs (all approved)." |
| ) |
| kind = "info" |
| |
| patch = state.mark_completed() |
| patch["revision_feedback"] = None |
| patch.update( |
| state.add_event( |
| agent="orchestrator", |
| stage=PipelineStage.COMPLETED, |
| kind=kind, |
| message=summary, |
| ) |
| ) |
| return patch |
|
|
|
|
|
|
|
|
| def pain_point_miner(state: VentureForgeState) -> dict:
|
| t0 = time.monotonic()
|
| result = run_pain_point_miner(state)
|
| elapsed = time.monotonic() - t0
|
| return {**result, **state.record_timing("pain_point_miner", elapsed)}
|
|
|
|
|
| def idea_generator(state: VentureForgeState) -> dict:
|
| t0 = time.monotonic()
|
| result = run_idea_generator(state)
|
| elapsed = time.monotonic() - t0
|
|
|
| patch = {**result, **state.record_timing("idea_generator", elapsed)}
|
| return patch
|
|
|
|
|
| def scorer(state: VentureForgeState) -> dict:
|
| t0 = time.monotonic()
|
| result = run_scorer(state)
|
| elapsed = time.monotonic() - t0
|
| return {**result, **state.record_timing("scorer", elapsed)}
|
|
|
|
|
| def pitch_writer(state: VentureForgeState) -> dict: |
| t0 = time.monotonic() |
| result = run_pitch_writer(state) |
| elapsed = time.monotonic() - t0 |
| return {**result, **state.record_timing("pitch_writer", elapsed)} |
|
|
|
|
| def critic(state: VentureForgeState) -> dict:
|
| t0 = time.monotonic()
|
| result = run_critic(state)
|
| elapsed = time.monotonic() - t0
|
| return {**result, **state.record_timing("critic", elapsed)}
|
|
|