265 lines
14 KiB
Python
265 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from decimal import Decimal
|
|
|
|
from agents.venture_discovery import PITCH_SECTIONS, SCORE_DIMENSIONS, VentureDiscoveryService
|
|
from control_plane.ventures.models import CapabilityPriority, CapabilityStatus, CompanyProposal, CompanyProposalStatus, ICDecisionType, VentureArtifact
|
|
from graph.bootstrap import champion_venture_discovery_graph_v1
|
|
from graph.langgraph_runtime import LangGraphRuntime
|
|
from graph.models import GraphRun, GraphRunStatus
|
|
from graph.venture_discovery import venture_discovery_registry
|
|
from model_router.router import ModelProvider, ModelRequestContract, ModelResponseContract, ModelRouter
|
|
|
|
|
|
class SolOneCompanyProvider(ModelProvider):
|
|
provider_name = "sol-test"
|
|
|
|
def complete(self, request: ModelRequestContract) -> ModelResponseContract:
|
|
return ModelResponseContract(
|
|
model="sol",
|
|
content=json.dumps(
|
|
{
|
|
"title": "LaunchLens",
|
|
"one_line_thesis": "A paid validation audit helps solo technical founders avoid wasting weeks on unvalidated AI microbusinesses.",
|
|
"description": "A fixed-scope validation and launch-readiness report for one startup idea.",
|
|
"problem": "Builders overbuild before proving demand.",
|
|
"target_customer": "Solo technical founders and small service operators considering an AI-assisted microbusiness.",
|
|
"proposed_solution": "A productized audit covering ICP, first-dollar path, validation gates, build plan, risks, and capability gaps.",
|
|
"business_model": "Productized service first, optional software later.",
|
|
"pricing_hypothesis": "$49-$99 per audit.",
|
|
"acquisition_strategy": "Compliant founder community posts and personal network conversations after approval.",
|
|
"validation_plan": "Collect 5 credible target-customer responses or 1 willingness-to-pay signal before build/spend.",
|
|
"capital_requested": "50",
|
|
"time_to_first_dollar_estimate": "3-10 days after outreach approval",
|
|
"expected_margin": "70-85% gross margin",
|
|
"build_complexity": "LOW",
|
|
"market_evidence": [{"type": "reasoning", "source": "sol", "summary": "Service-led validation minimizes build risk."}],
|
|
"differentiation": "IC-style diligence plus Artifex execution/capability-gap awareness.",
|
|
"major_risks": ["Demand unproven", "Distribution may fail", "Generic consulting competition"],
|
|
"confidence": 0.66,
|
|
}
|
|
),
|
|
metadata={"usage": {"prompt_tokens": 1, "completion_tokens": 1}},
|
|
)
|
|
|
|
def health(self) -> str:
|
|
return "AVAILABLE"
|
|
|
|
|
|
class ResearchProvider(ModelProvider):
|
|
provider_name = "luna-test"
|
|
|
|
def __init__(self, complete: bool = True) -> None:
|
|
self.complete_coverage = complete
|
|
|
|
def complete(self, request: ModelRequestContract) -> ModelResponseContract:
|
|
categories = ["competitors", "pricing", "customer_pain", "market_alternatives", "regulatory_platform_risks"] if self.complete_coverage else ["competitors"]
|
|
return ModelResponseContract(
|
|
model="luna",
|
|
content=json.dumps(
|
|
{
|
|
"sources": [{"url": f"https://example.com/{category}", "title": category.replace("_", " ").title(), "category": category, "summary": f"Source-linked evidence for LaunchLens validation audit serving solo technical founders with pricing, competitors, customer pain, market alternatives, regulatory compliance, tools, services, and subscription cost data for {category}."} for category in categories],
|
|
"findings": {category: f"Finding for {category}." for category in categories},
|
|
"coverage": {category: category in categories for category in ["competitors", "pricing", "customer_pain", "market_alternatives", "regulatory_platform_risks"]},
|
|
}
|
|
),
|
|
metadata={},
|
|
)
|
|
|
|
def health(self) -> str:
|
|
return "AVAILABLE"
|
|
|
|
|
|
class FakeSearchClient:
|
|
def search(self, query: str, *, category: str = "general", limit: int = 5) -> list[dict[str, object]]:
|
|
return [
|
|
{
|
|
"type": "public_web",
|
|
"source": "searxng",
|
|
"url": f"https://search.example/{category}",
|
|
"title": f"{category} source",
|
|
"category": category,
|
|
"summary": f"Search result for {query}",
|
|
"fallback_evidence": False,
|
|
}
|
|
]
|
|
|
|
|
|
class FakePageFetcher:
|
|
def fetch_many(self, sources: list[dict[str, object]], *, max_pages: int = 8) -> list[dict[str, object]]:
|
|
return [{"url": source["url"], "category": source["category"], "source_title": source["title"], "text": f"Fetched page text for {source['category']} with pricing, alternatives, and customer pain evidence.", "fetched_chars": 80} for source in sources[:max_pages]]
|
|
|
|
|
|
def service(*, web_research_available: bool = False, complete_research: bool = True) -> VentureDiscoveryService:
|
|
return VentureDiscoveryService(ModelRouter({"sol": SolOneCompanyProvider(), "luna": ResearchProvider(complete_research)}), web_research_available=web_research_available)
|
|
|
|
|
|
def test_venture_model_policy_overrides_ideation_and_research(monkeypatch) -> None:
|
|
monkeypatch.setenv("ARTIFEX_VENTURE_IDEATION_MODEL", "terra")
|
|
monkeypatch.setenv("ARTIFEX_VENTURE_RESEARCH_MODEL", "qwen")
|
|
router = ModelRouter({"terra": SolOneCompanyProvider(), "qwen": ResearchProvider()})
|
|
svc = VentureDiscoveryService(router, web_research_available=True)
|
|
|
|
proposal = svc.generate_single_company(svc.create_v0_mandate())
|
|
research = svc.conduct_market_research(proposal)
|
|
|
|
assert proposal.metadata["generation_source"] == "terra"
|
|
assert proposal.metadata["fallback_evidence"] is False
|
|
assert research["provider"] == "qwen"
|
|
|
|
|
|
def test_company_proposal_lifecycle_mandate_limits_and_pitch_schema() -> None:
|
|
svc = service()
|
|
mandate = svc.create_v0_mandate()
|
|
proposal = svc.generate_single_company(mandate)
|
|
|
|
assert mandate.max_validation_capital == Decimal("50")
|
|
assert mandate.target_net_new_cash == Decimal("500")
|
|
assert mandate.target_window_days == 30
|
|
assert mandate.constraints["no_real_spend_in_v0"] is True
|
|
assert mandate.constraints["no_real_customer_outreach_in_v0"] is True
|
|
assert CompanyProposal.objects.count() == 1
|
|
assert proposal.status == CompanyProposalStatus.SUBMITTED
|
|
assert proposal.capital_requested <= Decimal("50")
|
|
assert all(section in proposal.pitch for section in PITCH_SECTIONS)
|
|
assert proposal.metadata["generation_source"] == "sol"
|
|
assert proposal.metadata["real_spend"] == 0
|
|
assert proposal.metadata["real_customer_outreach"] is False
|
|
|
|
|
|
def test_board_review_ic_questions_responses_and_bounded_diligence() -> None:
|
|
svc = service()
|
|
proposal = svc.generate_single_company(svc.create_v0_mandate())
|
|
board = svc.board_review(proposal)
|
|
diligence = svc.start_ic_diligence(proposal)
|
|
questions = svc.generate_ic_questions(diligence)
|
|
responses = svc.answer_questions(diligence)
|
|
challenge = svc.red_team(diligence)
|
|
final = svc.final_company_response(diligence)
|
|
|
|
assert set(board.observations) == {"CEO", "CTO", "CFO", "CRO", "Independent Director"}
|
|
assert board.metadata["company_does_not_grade_itself"] is True
|
|
assert diligence.rounds == ["Initial Pitch", "Diligence Round 1", "Final Challenge", "Decision"]
|
|
assert diligence.metadata["bounded_rounds"] is True
|
|
assert len(questions) == 8
|
|
assert all(q.evidence_required for q in questions)
|
|
assert len(responses) == len(questions)
|
|
assert all(r.metadata["no_customer_outreach"] and r.metadata["no_spend"] for r in responses)
|
|
assert challenge["recommendation"] == "continue_to_final_response"
|
|
assert final["kill_criteria"]
|
|
|
|
|
|
def test_ic_scoring_decision_capability_gap_and_memo_artifact() -> None:
|
|
svc = service(web_research_available=True)
|
|
proposal = svc.generate_single_company(svc.create_v0_mandate())
|
|
research = svc.conduct_market_research(proposal)
|
|
svc.board_review(proposal)
|
|
diligence = svc.start_ic_diligence(proposal)
|
|
svc.generate_ic_questions(diligence)
|
|
svc.answer_questions(diligence)
|
|
svc.red_team(diligence)
|
|
svc.final_company_response(diligence)
|
|
decision = svc.score_and_decide(diligence)
|
|
gap = svc.capability_analysis(proposal)
|
|
memo = svc.produce_investment_memo(diligence, gap)
|
|
|
|
assert decision.decision in ICDecisionType.values
|
|
assert set(SCORE_DIMENSIONS) == set(decision.component_scores)
|
|
assert decision.decision == ICDecisionType.CONDITIONAL_FUND
|
|
assert decision.initial_tranche in {Decimal("10.00"), Decimal("20.00")}
|
|
assert "5 credible target-customer responses" in decision.validation_condition
|
|
assert decision.metadata["no_actual_funding"] is True
|
|
assert len(research["sources"]) == 5
|
|
assert "WEB_MARKET_RESEARCH" in gap.available
|
|
assert gap.metadata["web_market_research_status"] == "AVAILABLE"
|
|
assert {item["priority"] for item in gap.ranked_missing}.issuperset({CapabilityPriority.BEFORE_VALIDATION, CapabilityPriority.BEFORE_FIRST_CUSTOMER})
|
|
assert memo.artifact_type == "FINAL_INVESTMENT_MEMO"
|
|
assert memo.content["Company"] == proposal.title
|
|
assert "Capability gap" in memo.content
|
|
|
|
|
|
def test_searxng_search_and_page_fetch_sources_can_supply_market_research_evidence() -> None:
|
|
svc = VentureDiscoveryService(ModelRouter({}), web_research_available=True, search_client=FakeSearchClient(), page_fetcher=FakePageFetcher())
|
|
proposal = svc.generate_single_company(svc.create_v0_mandate())
|
|
|
|
research = svc.conduct_market_research(proposal)
|
|
|
|
assert research["search_provider"] == "searxng"
|
|
assert len(research["sources"]) == 5
|
|
assert research["page_fetch_count"] == 5
|
|
assert "Fetched page text" in research["page_corpus"][0]["text"]
|
|
assert proposal.metadata["research"]["page_fetch_count"] == 5
|
|
assert all(research["coverage"].values())
|
|
|
|
|
|
def test_fallback_is_marked_and_reduces_confidence_when_research_unavailable() -> None:
|
|
svc = VentureDiscoveryService(web_research_available=False)
|
|
proposal = svc.generate_single_company(svc.create_v0_mandate())
|
|
gap = svc.capability_analysis(proposal)
|
|
|
|
assert proposal.metadata["generation_source"] == "deterministic_fallback"
|
|
assert proposal.metadata["fallback_evidence"] is True
|
|
assert proposal.confidence <= 0.58
|
|
assert any(item.get("fallback_evidence") for item in proposal.market_evidence)
|
|
assert "WEB_MARKET_RESEARCH" in gap.missing
|
|
|
|
|
|
def test_venture_discovery_v1_graph_lineage_and_no_automatic_execution() -> None:
|
|
version = champion_venture_discovery_graph_v1()
|
|
graph_run = GraphRun.objects.create(execution_graph_version=version, current_node=version.graph_spec["entry"])
|
|
|
|
LangGraphRuntime(venture_discovery_registry(service())).run_until_terminal_or_paused(graph_run)
|
|
graph_run.refresh_from_db()
|
|
|
|
assert graph_run.status == GraphRunStatus.COMPLETE
|
|
assert graph_run.execution_graph_version.graph.name == "venture_discovery"
|
|
assert graph_run.execution_graph_version.version == 1
|
|
assert CompanyProposal.objects.count() == 1
|
|
proposal = CompanyProposal.objects.get()
|
|
assert proposal.metadata["real_spend"] == 0
|
|
assert proposal.metadata["real_customer_outreach"] is False
|
|
assert graph_run.node_runs.count() == 14
|
|
assert graph_run.edge_traversals.count() == 14
|
|
artifact_types = set(VentureArtifact.objects.values_list("artifact_type", flat=True))
|
|
assert {"STANDARDIZED_COMPANY_PITCH", "MARKET_RESEARCH", "COMPANY_BOARD_REVIEW", "IC_QUESTIONS", "IC_RESPONSES", "IC_RED_TEAM", "IC_FINAL_RESPONSE", "IC_FINAL_SCORE", "CAPABILITY_GAP_REPORT", "FINAL_INVESTMENT_MEMO"}.issubset(artifact_types)
|
|
decision = proposal.ic_diligence.get().decision
|
|
svc = service()
|
|
svc.request_human_approval(decision, "approve_for_validation")
|
|
decision.refresh_from_db()
|
|
assert decision.metadata["real_spend_still_blocked"] is True
|
|
|
|
|
|
def test_materially_different_pitches_receive_different_scores_and_tranches() -> None:
|
|
strong = service(web_research_available=True, complete_research=True)
|
|
strong_proposal = strong.generate_single_company(strong.create_v0_mandate())
|
|
strong.conduct_market_research(strong_proposal)
|
|
strong_diligence = strong.start_ic_diligence(strong_proposal)
|
|
strong.generate_ic_questions(strong_diligence)
|
|
strong.answer_questions(strong_diligence)
|
|
strong.red_team(strong_diligence)
|
|
strong.final_company_response(strong_diligence)
|
|
strong_decision = strong.score_and_decide(strong_diligence)
|
|
|
|
weak = service(web_research_available=True, complete_research=False)
|
|
weak_proposal = weak.generate_single_company(weak.create_v0_mandate())
|
|
weak_proposal.title = "Enterprise RegTech Platform"
|
|
weak_proposal.business_model = "SaaS platform requiring integrations and long enterprise sales cycles."
|
|
weak_proposal.build_complexity = "HIGH"
|
|
weak_proposal.expected_margin = "35-45% gross margin before support burden"
|
|
weak_proposal.validation_plan = "Build a prototype and then seek feedback."
|
|
weak_proposal.pitch = {**weak_proposal.pitch, "Company name": weak_proposal.title, "Business model": weak_proposal.business_model, "Validation plan": weak_proposal.validation_plan}
|
|
weak_proposal.save(update_fields=["title", "business_model", "build_complexity", "expected_margin", "validation_plan", "pitch", "updated_at"])
|
|
weak.conduct_market_research(weak_proposal)
|
|
weak_diligence = weak.start_ic_diligence(weak_proposal)
|
|
weak.generate_ic_questions(weak_diligence)
|
|
weak.answer_questions(weak_diligence)
|
|
weak.red_team(weak_diligence)
|
|
weak.final_company_response(weak_diligence)
|
|
weak_decision = weak.score_and_decide(weak_diligence)
|
|
|
|
assert strong_decision.component_scores["Demand Evidence"] > weak_decision.component_scores["Demand Evidence"]
|
|
assert strong_decision.component_scores["Build Simplicity"] > weak_decision.component_scores["Build Simplicity"]
|
|
assert strong_decision.composite_score - weak_decision.composite_score >= 10
|
|
assert strong_decision.probability_500_within_30_days > weak_decision.probability_500_within_30_days
|
|
assert strong_decision.initial_tranche != weak_decision.initial_tranche
|