Add model policy and SearXNG research
This commit is contained in:
parent
5f58354772
commit
61c72e300f
13 changed files with 321 additions and 29 deletions
|
|
@ -74,7 +74,7 @@ class ProjectContextMixin:
|
|||
def _json_from_sol(self, router: ModelRouter | None, prompt: str, *, fallback: dict[str, object], project: Project | None = None) -> dict[str, object]:
|
||||
if router is None:
|
||||
return fallback
|
||||
response = router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, prompt=prompt, model_hint="sol", project=project))
|
||||
response = router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, prompt=prompt, project=project))
|
||||
try:
|
||||
payload = json.loads(response.content)
|
||||
except json.JSONDecodeError as exc:
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class RoadmapService(ProjectContextMixin):
|
|||
return fallback
|
||||
payload = {"context": self.project_context(project), "roadmap_items": list(project.roadmap_items.values("id", "title", "description", "horizon", "status", "target_action", "value_score", "effort_score", "risk_score", "confidence", "strategic_fit", "technical_fit", "urgency", "composite_score"))}
|
||||
try:
|
||||
response = self.router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, model_hint="sol", project=project, prompt="Review this existing project roadmap. Return JSON with recommendations: item_id, recommendation, rationale, optional horizon/status. Do not execute work.\n" + json.dumps(payload, default=str)))
|
||||
response = self.router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, project=project, prompt="Review this existing project roadmap. Return JSON with recommendations: item_id, recommendation, rationale, optional horizon/status. Do not execute work.\n" + json.dumps(payload, default=str)))
|
||||
parsed = json.loads(response.content)
|
||||
return parsed if isinstance(parsed, dict) else fallback
|
||||
except Exception as exc:
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class ScenarioLabService(ProjectContextMixin):
|
|||
payload = fallback
|
||||
if self.router is not None:
|
||||
try:
|
||||
response = self.router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, model_hint="sol", project=suite.project, prompt="Design safe Scenario Lab candidates for this existing project. Return JSON with scenarios. Each scenario needs type, injected_condition, expected_invariants, success_criteria, and resource_budget. Do not create executable work.\n" + json.dumps(self.project_context(suite.project), default=str)))
|
||||
response = self.router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, project=suite.project, prompt="Design safe Scenario Lab candidates for this existing project. Return JSON with scenarios. Each scenario needs type, injected_condition, expected_invariants, success_criteria, and resource_budget. Do not create executable work.\n" + json.dumps(self.project_context(suite.project), default=str)))
|
||||
parsed = json.loads(response.content)
|
||||
if isinstance(parsed, dict) and isinstance(parsed.get("scenarios"), list):
|
||||
payload = parsed
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ from control_plane.events.bus import EventBus
|
|||
from control_plane.ventures.models import CapabilityPriority, CapabilityStatus, CompanyBoardReview, CompanyCapabilityRequirement, CompanyMandate, CompanyProposal, CompanyProposalStatus, EvidenceTier, ICDecision, ICDecisionType, ICDiligence, ICQuestion, ICResponse, OverlapClassification, PortfolioCapabilityGap, PortfolioICReview, VentureArtifact, VentureCapabilityDemand, VentureCohort, VentureCohortMember, VentureCollision, VentureThesis, VentureThesisFingerprint
|
||||
from graph.models import GraphRun, GraphRunStatus
|
||||
from model_router.providers import extract_json_object
|
||||
from model_router.policy import model_for_role
|
||||
from model_router.router import ModelCapability, ModelRequestContract, ModelRouter
|
||||
from research.searxng import SearxngSearchClient, WebPageFetcher
|
||||
|
||||
|
||||
PITCH_SECTIONS = ["Company name", "One-line thesis", "Problem", "ICP", "Why now", "Product / service", "Business model", "Pricing", "Route to first customer", "Validation plan", "$50 capital allocation proposal", "Time to first dollar", "Path to $500 net cash", "Competition", "Differentiation", "Build requirements", "Distribution requirements", "Risks", "What would falsify the thesis", "Confidence"]
|
||||
|
|
@ -29,11 +31,14 @@ AI_NATIVE_POLICY = {"preference": "Prefer AI-native opportunities where Artifex
|
|||
|
||||
|
||||
class VentureDiscoveryService:
|
||||
def __init__(self, router: ModelRouter | None = None, bus: EventBus | None = None, web_research_available: bool = False, research_model_hint: str = "luna") -> None:
|
||||
def __init__(self, router: ModelRouter | None = None, bus: EventBus | None = None, web_research_available: bool = False, research_model_hint: str | None = None, ideation_model_hint: str | None = None, search_client: Any | None = None, page_fetcher: Any | None = None) -> None:
|
||||
self.router = router
|
||||
self.bus = bus or EventBus()
|
||||
self.web_research_available = web_research_available
|
||||
self.research_model_hint = research_model_hint
|
||||
self.research_model_hint = research_model_hint or model_for_role("venture_research")
|
||||
self.ideation_model_hint = ideation_model_hint or model_for_role("venture_ideation")
|
||||
self.search_client = search_client
|
||||
self.page_fetcher = page_fetcher or WebPageFetcher()
|
||||
self._last_bounded_map_peak = 0
|
||||
|
||||
def create_v0_mandate(self) -> CompanyMandate:
|
||||
|
|
@ -48,7 +53,7 @@ class VentureDiscoveryService:
|
|||
|
||||
def generate_single_company(self, mandate: CompanyMandate, *, graph_run=None, ideation_index: int | None = None) -> CompanyProposal:
|
||||
payload, source = self._company_payload(mandate, ideation_index=ideation_index)
|
||||
pitch = self._pitch(payload, fallback=source != "sol")
|
||||
pitch = self._pitch(payload, fallback=source == "deterministic_fallback")
|
||||
confidence = self._confidence(payload.get("confidence", 0.55))
|
||||
evidence = self._as_list(payload.get("market_evidence", []))
|
||||
if not self.web_research_available:
|
||||
|
|
@ -77,19 +82,21 @@ class VentureDiscoveryService:
|
|||
confidence=confidence,
|
||||
status=CompanyProposalStatus.SUBMITTED,
|
||||
pitch=pitch,
|
||||
metadata={"generation_source": source, "fallback_evidence": source != "sol", "web_research_available": self.web_research_available, "real_spend": 0, "real_customer_outreach": False},
|
||||
metadata={"generation_source": source, "fallback_evidence": source == "deterministic_fallback", "web_research_available": self.web_research_available, "real_spend": 0, "real_customer_outreach": False},
|
||||
evidence_tier=EvidenceTier.TIER_0_THESIS,
|
||||
)
|
||||
self._artifact(proposal, mandate, "STANDARDIZED_COMPANY_PITCH", "Standardized Company Pitch", pitch, self.readable_pitch(pitch), "Company Brain/Sol" if source == "sol" else "deterministic_fallback", graph_run=graph_run)
|
||||
self._artifact(proposal, mandate, "STANDARDIZED_COMPANY_PITCH", "Standardized Company Pitch", pitch, self.readable_pitch(pitch), f"Company Brain/{source}" if source != "deterministic_fallback" else "deterministic_fallback", graph_run=graph_run)
|
||||
self.bus.publish("VENTURE_COMPANY_PROPOSED", payload={"proposal_id": str(proposal.id), "source": source})
|
||||
return proposal
|
||||
|
||||
def conduct_market_research(self, proposal: CompanyProposal, *, graph_run=None) -> dict[str, Any]:
|
||||
required = ["competitors", "pricing", "customer_pain", "market_alternatives", "regulatory_platform_risks"]
|
||||
research = {"coverage": {category: False for category in required}, "sources": [], "findings": {}, "unverified_categories": required, "research_available": False}
|
||||
search_sources = self._searxng_sources(proposal, required) if self.web_research_available else []
|
||||
page_corpus = self._page_corpus(search_sources) if search_sources else []
|
||||
if self.web_research_available and self.router is not None:
|
||||
try:
|
||||
response = self.router.complete(ModelRequestContract(purpose=ModelCapability.REASONING, model_hint=self.research_model_hint, prompt="Bounded public web market research for exactly one startup pitch. Return JSON with keys: sources (list of {url,title,category,summary}), findings (object keyed by competitors, pricing, customer_pain, market_alternatives, regulatory_platform_risks), and coverage (object with each required key true/false). Do not fabricate URLs. If a category has no source, mark coverage false. Pitch: " + json.dumps(proposal.pitch, default=str)))
|
||||
response = self.router.complete(ModelRequestContract(purpose=ModelCapability.REASONING, model_hint=self.research_model_hint, prompt="Bounded public web market research for exactly one startup pitch. Return JSON with keys: sources (list of {url,title,category,summary}), findings (object keyed by competitors, pricing, customer_pain, market_alternatives, regulatory_platform_risks), and coverage (object with each required key true/false). Use only the provided SearXNG search context and fetched page excerpts for source URLs; do not fabricate URLs. If a category has no source, mark coverage false. Search context: " + json.dumps(search_sources, default=str) + " Page excerpts: " + json.dumps(page_corpus, default=str) + " Pitch: " + json.dumps(proposal.pitch, default=str)))
|
||||
parsed = extract_json_object(response.content)
|
||||
if isinstance(parsed, dict):
|
||||
research = self._normalize_research(parsed, required)
|
||||
|
|
@ -97,6 +104,10 @@ class VentureDiscoveryService:
|
|||
research["provider"] = self.research_model_hint
|
||||
except Exception as exc:
|
||||
research["failure"] = str(exc)
|
||||
if search_sources:
|
||||
research = self._merge_search_sources(research, search_sources, required)
|
||||
if page_corpus:
|
||||
research = {**research, "page_corpus": page_corpus, "page_fetch_count": len(page_corpus)}
|
||||
if not research.get("sources"):
|
||||
research = self._missing_research(required, research.get("failure", "public web research unavailable or returned no source-linked evidence"))
|
||||
source_categories = {str(source.get("category", "")) for source in research.get("sources", []) if source.get("url")}
|
||||
|
|
@ -108,14 +119,14 @@ class VentureDiscoveryService:
|
|||
coverage_ratio = (len(required) - len(research["unverified_categories"])) / len(required)
|
||||
proposal.confidence = round(min(float(proposal.confidence), 0.45 + 0.35 * coverage_ratio), 2)
|
||||
proposal.market_evidence = [*self._as_list(proposal.market_evidence), *research.get("sources", []), {"type": "research_coverage", "source": "venture_research", "summary": f"Source-linked research coverage: {round(coverage_ratio * 100)}%", "coverage": coverage, "unverified_categories": research["unverified_categories"]}]
|
||||
proposal.metadata = {**proposal.metadata, "research": {"coverage_ratio": coverage_ratio, "unverified_categories": research["unverified_categories"], "source_count": len(research.get("sources", [])), "provider": research.get("provider", "none")}}
|
||||
proposal.metadata = {**proposal.metadata, "research": {"coverage_ratio": coverage_ratio, "unverified_categories": research["unverified_categories"], "source_count": len(research.get("sources", [])), "page_fetch_count": research.get("page_fetch_count", 0), "provider": research.get("provider", "none"), "search_provider": research.get("search_provider", "none")}}
|
||||
proposal.evidence_tier = EvidenceTier.TIER_1_PUBLIC_EVIDENCE if coverage_ratio > 0 else EvidenceTier.TIER_0_THESIS
|
||||
if proposal.thesis:
|
||||
proposal.thesis.evidence_tier = proposal.evidence_tier
|
||||
proposal.thesis.save(update_fields=["evidence_tier", "updated_at"])
|
||||
proposal.pitch = {**proposal.pitch, "Research evidence": research}
|
||||
proposal.save(update_fields=["confidence", "market_evidence", "metadata", "pitch", "evidence_tier", "updated_at"])
|
||||
self._artifact(proposal, proposal.mandate, "MARKET_RESEARCH", "Bounded Public Market Research", research, self._readable_research(research), "Luna/web research" if research.get("provider") else "research_gap", graph_run=graph_run)
|
||||
self._artifact(proposal, proposal.mandate, "MARKET_RESEARCH", "Bounded Public Market Research", research, self._readable_research(research), f"{research.get('provider', 'search')}/web research" if research.get("sources") else "research_gap", graph_run=graph_run)
|
||||
return research
|
||||
|
||||
def board_review(self, proposal: CompanyProposal, *, graph_run=None) -> CompanyBoardReview:
|
||||
|
|
@ -466,10 +477,10 @@ class VentureDiscoveryService:
|
|||
if self.router is not None:
|
||||
try:
|
||||
slot = f" Independent cohort slot: {ideation_index}. Do not use or imitate other cohort ideas; no other ideas are visible." if ideation_index else ""
|
||||
response = self.router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, model_hint="sol", prompt="Generate exactly ONE startup idea for Venture Discovery V0. Return a single JSON object, not a list. Respect no spend and no outreach in V0. Keep the $50 to $500 in 30 days mandate. Prefer, but do not require, AI-native businesses where Artifex can deliver most value using agents/local inference: AI wrapper platforms, AI-enabled services, AI infrastructure/developer tools, AI intelligence products, or SMB/enterprise workflow automation. Do not let AI novelty outweigh customer demand. Penalize generic audits, emergency fix services, one-off consulting, Shopify/ecommerce concentration, and identical outbound-led service models unless exceptional. Include title, one_line_thesis, description, problem, target_customer, proposed_solution, business_model, pricing_hypothesis, acquisition_strategy, validation_plan, capital_requested, time_to_first_dollar_estimate, expected_margin, build_complexity, market_evidence, differentiation, major_risks, confidence." + slot + " Mandate: " + json.dumps({"objective": mandate.objective, "constraints": mandate.constraints, "optimization_targets": mandate.optimization_targets, "ai_native_policy": AI_NATIVE_POLICY})))
|
||||
response = self.router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, model_hint=self.ideation_model_hint, prompt="Generate exactly ONE startup idea for Venture Discovery V0. Return a single JSON object, not a list. Respect no spend and no outreach in V0. Keep the $50 to $500 in 30 days mandate. Prefer, but do not require, AI-native businesses where Artifex can deliver most value using agents/local inference: AI wrapper platforms, AI-enabled services, AI infrastructure/developer tools, AI intelligence products, or SMB/enterprise workflow automation. Do not let AI novelty outweigh customer demand. Penalize generic audits, emergency fix services, one-off consulting, Shopify/ecommerce concentration, and identical outbound-led service models unless exceptional. Include title, one_line_thesis, description, problem, target_customer, proposed_solution, business_model, pricing_hypothesis, acquisition_strategy, validation_plan, capital_requested, time_to_first_dollar_estimate, expected_margin, build_complexity, market_evidence, differentiation, major_risks, confidence." + slot + " Mandate: " + json.dumps({"objective": mandate.objective, "constraints": mandate.constraints, "optimization_targets": mandate.optimization_targets, "ai_native_policy": AI_NATIVE_POLICY})))
|
||||
parsed = extract_json_object(response.content)
|
||||
if isinstance(parsed, dict) and parsed.get("title"):
|
||||
return self._normalize_payload(parsed), "sol"
|
||||
return self._normalize_payload(parsed), self.ideation_model_hint
|
||||
except Exception:
|
||||
pass
|
||||
payload = self._fallback_company_payload()
|
||||
|
|
@ -495,6 +506,49 @@ class VentureDiscoveryService:
|
|||
coverage = {category: bool(dict(payload.get("coverage", {})).get(category)) for category in required}
|
||||
return {"coverage": coverage, "sources": sources, "findings": dict(payload.get("findings", {})), "unverified_categories": []}
|
||||
|
||||
def _searxng_sources(self, proposal: CompanyProposal, required: list[str]) -> list[dict[str, Any]]:
|
||||
client = self.search_client or SearxngSearchClient.from_resources()
|
||||
if client is None:
|
||||
return []
|
||||
sources = []
|
||||
query_terms = {
|
||||
"competitors": "competitors alternatives",
|
||||
"pricing": "pricing cost",
|
||||
"customer_pain": "customer pain problem forum",
|
||||
"market_alternatives": "alternatives tools services",
|
||||
"regulatory_platform_risks": "regulatory platform risk compliance",
|
||||
}
|
||||
for category in required:
|
||||
query = f"{proposal.title} {proposal.target_customer} {query_terms.get(category, category)}"
|
||||
try:
|
||||
sources.extend(client.search(query, category=category, limit=3))
|
||||
except Exception:
|
||||
continue
|
||||
return sources[:15]
|
||||
|
||||
def _page_corpus(self, search_sources: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if self.page_fetcher is None:
|
||||
return []
|
||||
try:
|
||||
return self.page_fetcher.fetch_many(search_sources, max_pages=8)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _merge_search_sources(self, research: dict[str, Any], search_sources: list[dict[str, Any]], required: list[str]) -> dict[str, Any]:
|
||||
seen = {source.get("url") for source in research.get("sources", [])}
|
||||
merged_sources = [*research.get("sources", [])]
|
||||
for source in search_sources:
|
||||
if source.get("url") in seen:
|
||||
continue
|
||||
seen.add(source.get("url"))
|
||||
merged_sources.append(source)
|
||||
coverage = {category: bool(dict(research.get("coverage", {})).get(category)) for category in required}
|
||||
for source in merged_sources:
|
||||
category = str(source.get("category", ""))
|
||||
if category in coverage and source.get("url"):
|
||||
coverage[category] = True
|
||||
return {**research, "coverage": coverage, "sources": merged_sources, "research_available": True, "search_provider": "searxng"}
|
||||
|
||||
def _missing_research(self, required: list[str], reason: str) -> dict[str, Any]:
|
||||
return {"coverage": {category: False for category in required}, "sources": [], "findings": {}, "unverified_categories": required, "research_available": False, "failure": reason}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,18 +5,21 @@ from django.utils import timezone
|
|||
|
||||
from control_plane.resources.models import Resource
|
||||
from model_router.providers import QwenProvider, SolProvider
|
||||
from research.searxng import SearxngSearchClient
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Check configured model provider health without crashing the control plane."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
resources = sorted(Resource.objects.filter(is_active=True, kind="MODEL"), key=self.sort_key)
|
||||
resources = sorted(Resource.objects.filter(is_active=True, provider__in=["local_inference", "opencode", "searxng"]), key=self.sort_key)
|
||||
for resource in resources:
|
||||
if resource.provider == "opencode":
|
||||
status = SolProvider(resource).health()
|
||||
elif resource.provider == "local_inference":
|
||||
status = QwenProvider(resource).health()
|
||||
elif resource.provider == "searxng":
|
||||
status = SearxngSearchClient(endpoint_url=str(resource.config.get("endpoint_url", "http://127.0.0.1:8080"))).health()
|
||||
else:
|
||||
status = "UNAVAILABLE"
|
||||
resource.health_status = status
|
||||
|
|
@ -28,5 +31,7 @@ class Command(BaseCommand):
|
|||
model_key = str(resource.config.get("model_key") or "").lower()
|
||||
if resource.provider == "local_inference":
|
||||
model_key = "qwen"
|
||||
order = {"qwen": 0, "sol": 1, "terra": 2, "luna": 3}
|
||||
if resource.provider == "searxng":
|
||||
model_key = "searxng"
|
||||
order = {"qwen": 0, "sol": 1, "terra": 2, "luna": 3, "searxng": 4}
|
||||
return order.get(model_key, 100), resource.name
|
||||
|
|
|
|||
|
|
@ -93,4 +93,17 @@ class Command(BaseCommand):
|
|||
},
|
||||
},
|
||||
)
|
||||
Resource.objects.update_or_create(
|
||||
name=os.environ.get("ARTIFEX_SEARXNG_RESOURCE_NAME", "SearXNG Search"),
|
||||
defaults={
|
||||
"kind": ResourceKind.COMPUTE,
|
||||
"provider": "searxng",
|
||||
"compute": spark,
|
||||
"roles": ["WEB_RESEARCH", "MARKET_RESEARCH", "SEARCH"],
|
||||
"config": {
|
||||
"endpoint_url": os.environ.get("ARTIFEX_SEARXNG_ENDPOINT_URL", "http://127.0.0.1:8080"),
|
||||
"timeout_seconds": int(os.environ.get("ARTIFEX_SEARXNG_TIMEOUT_SECONDS", "20")),
|
||||
},
|
||||
},
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS("Seeded Spark model resources."))
|
||||
|
|
|
|||
60
model_router/policy.py
Normal file
60
model_router/policy.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
DEFAULT_MODEL_POLICY = {
|
||||
"planning": "sol",
|
||||
"project_brain": "sol",
|
||||
"archaeology_interpretation": "sol",
|
||||
"agent_design": "sol",
|
||||
"escalation": "sol",
|
||||
"coding": "qwen",
|
||||
"review": "qwen",
|
||||
"reasoning": "qwen",
|
||||
"venture_ideation": "sol",
|
||||
"venture_research": "luna",
|
||||
"venture_portfolio_ic": "terra",
|
||||
}
|
||||
|
||||
|
||||
ENV_BY_ROLE = {
|
||||
"planning": "ARTIFEX_PLANNING_MODEL",
|
||||
"project_brain": "ARTIFEX_PROJECT_BRAIN_MODEL",
|
||||
"archaeology_interpretation": "ARTIFEX_ARCHAEOLOGY_MODEL",
|
||||
"agent_design": "ARTIFEX_AGENT_DESIGN_MODEL",
|
||||
"escalation": "ARTIFEX_ESCALATION_MODEL",
|
||||
"coding": "ARTIFEX_CODING_MODEL",
|
||||
"review": "ARTIFEX_REVIEW_MODEL",
|
||||
"reasoning": "ARTIFEX_REASONING_MODEL",
|
||||
"venture_ideation": "ARTIFEX_VENTURE_IDEATION_MODEL",
|
||||
"venture_research": "ARTIFEX_VENTURE_RESEARCH_MODEL",
|
||||
"venture_portfolio_ic": "ARTIFEX_VENTURE_PORTFOLIO_IC_MODEL",
|
||||
}
|
||||
|
||||
|
||||
PURPOSE_TO_ROLE = {
|
||||
"PROJECT_BRAIN": "project_brain",
|
||||
"PLANNING": "planning",
|
||||
"ARCHAEOLOGY_INTERPRETATION": "archaeology_interpretation",
|
||||
"AGENT_DESIGN": "agent_design",
|
||||
"ESCALATION": "escalation",
|
||||
"CODING": "coding",
|
||||
"REVIEW": "review",
|
||||
"REASONING": "reasoning",
|
||||
}
|
||||
|
||||
|
||||
def model_for_role(role: str) -> str:
|
||||
normalized = role.lower()
|
||||
env_name = ENV_BY_ROLE.get(normalized)
|
||||
if env_name:
|
||||
configured = os.environ.get(env_name)
|
||||
if configured:
|
||||
return configured.strip().lower()
|
||||
return DEFAULT_MODEL_POLICY.get(normalized, DEFAULT_MODEL_POLICY["reasoning"])
|
||||
|
||||
|
||||
def model_for_purpose(purpose: str) -> str:
|
||||
role = PURPOSE_TO_ROLE.get(str(purpose).upper(), "reasoning")
|
||||
return model_for_role(role)
|
||||
|
|
@ -11,6 +11,7 @@ from django.utils import timezone
|
|||
from control_plane.agents.models import AgentVersion
|
||||
from control_plane.projects.models import Project
|
||||
from control_plane.resources.models import ModelRequest, Resource
|
||||
from model_router.policy import model_for_purpose
|
||||
|
||||
|
||||
class ModelCapability(StrEnum):
|
||||
|
|
@ -53,18 +54,7 @@ class ModelRouter:
|
|||
self.persist_requests = persist_requests
|
||||
|
||||
def route(self, purpose: str) -> str:
|
||||
normalized = purpose.upper()
|
||||
if normalized in {
|
||||
ModelCapability.PROJECT_BRAIN,
|
||||
ModelCapability.PLANNING,
|
||||
ModelCapability.ARCHAEOLOGY_INTERPRETATION,
|
||||
"PLANNING",
|
||||
"ARCHAEOLOGY_INTERPRETATION",
|
||||
"AGENT_DESIGN",
|
||||
"ESCALATION",
|
||||
}:
|
||||
return "sol"
|
||||
return "qwen"
|
||||
return model_for_purpose(str(purpose))
|
||||
|
||||
def complete(self, request: ModelRequestContract) -> ModelResponseContract:
|
||||
provider_key = request.model_hint or self.route(request.purpose)
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ class SolProjectBrain:
|
|||
|
||||
def plan_project(self, prompt: PlanningPrompt) -> str:
|
||||
response = self.router.complete(
|
||||
ModelRequestContract(purpose="PLANNING", prompt=prompt.idea, model_hint="sol")
|
||||
ModelRequestContract(purpose="PLANNING", prompt=prompt.idea)
|
||||
)
|
||||
return response.content
|
||||
|
||||
def create_project_plan_contract(self, prompt: PlanningPrompt) -> ProjectPlanContract:
|
||||
response = self.router.complete(ModelRequestContract(purpose="PLANNING", prompt=prompt.idea, model_hint="sol"))
|
||||
response = self.router.complete(ModelRequestContract(purpose="PLANNING", prompt=prompt.idea))
|
||||
return parse_project_plan_response(response.content)
|
||||
|
|
|
|||
1
research/__init__.py
Normal file
1
research/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
112
research/searxng.py
Normal file
112
research/searxng.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from html import unescape
|
||||
from typing import Any
|
||||
|
||||
from control_plane.resources.models import Resource
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearxngSearchClient:
|
||||
endpoint_url: str
|
||||
timeout_seconds: int = 20
|
||||
|
||||
@classmethod
|
||||
def from_resources(cls) -> "SearxngSearchClient | None":
|
||||
resource = Resource.objects.filter(is_active=True, provider="searxng").first()
|
||||
if resource is None:
|
||||
return None
|
||||
return cls(endpoint_url=str(resource.config.get("endpoint_url", "http://127.0.0.1:8080")), timeout_seconds=int(resource.config.get("timeout_seconds", 20)))
|
||||
|
||||
def health(self) -> str:
|
||||
try:
|
||||
with urllib.request.urlopen(self.endpoint_url.rstrip("/") + "/", timeout=5) as response:
|
||||
return "AVAILABLE" if 200 <= response.status < 500 else "DEGRADED"
|
||||
except Exception:
|
||||
return "UNAVAILABLE"
|
||||
|
||||
def search(self, query: str, *, category: str = "general", limit: int = 5) -> list[dict[str, Any]]:
|
||||
params = urllib.parse.urlencode({"q": query, "format": "json", "categories": "general", "language": "en"})
|
||||
url = self.endpoint_url.rstrip("/") + "/search?" + params
|
||||
request = urllib.request.Request(url, headers={"Accept": "application/json"}, method="GET")
|
||||
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
results = []
|
||||
for item in payload.get("results", [])[:limit]:
|
||||
if not isinstance(item, dict) or not item.get("url"):
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"type": "public_web",
|
||||
"source": "searxng",
|
||||
"url": str(item["url"]),
|
||||
"title": str(item.get("title", "")),
|
||||
"category": category,
|
||||
"summary": str(item.get("content", item.get("snippet", ""))),
|
||||
"fallback_evidence": False,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebPageFetcher:
|
||||
timeout_seconds: int = 12
|
||||
max_bytes: int = 200_000
|
||||
max_text_chars: int = 4_000
|
||||
|
||||
def fetch_many(self, sources: list[dict[str, Any]], *, max_pages: int = 8) -> list[dict[str, Any]]:
|
||||
pages = []
|
||||
seen = set()
|
||||
for source in sources:
|
||||
url = str(source.get("url", ""))
|
||||
if url in seen or not self._allowed_url(url):
|
||||
continue
|
||||
seen.add(url)
|
||||
page = self.fetch(url)
|
||||
if page is None:
|
||||
continue
|
||||
pages.append({**page, "category": source.get("category", ""), "source_title": source.get("title", "")})
|
||||
if len(pages) >= max_pages:
|
||||
break
|
||||
return pages
|
||||
|
||||
def fetch(self, url: str) -> dict[str, Any] | None:
|
||||
if not self._allowed_url(url):
|
||||
return None
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "ArtifexResearchBot/0.1 (+local bounded research)"}, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
raw = response.read(self.max_bytes)
|
||||
except Exception:
|
||||
return None
|
||||
text = raw.decode("utf-8", errors="ignore")
|
||||
if "html" in content_type.lower() or "<html" in text[:500].lower():
|
||||
text = self._html_to_text(text)
|
||||
else:
|
||||
text = self._clean_text(text)
|
||||
if not text:
|
||||
return None
|
||||
return {"url": url, "content_type": content_type, "text": text[: self.max_text_chars], "fetched_chars": min(len(text), self.max_text_chars)}
|
||||
|
||||
def _allowed_url(self, url: str) -> bool:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
|
||||
def _html_to_text(self, html: str) -> str:
|
||||
html = re.sub(r"(?is)<(script|style|noscript|svg).*?</\1>", " ", html)
|
||||
html = re.sub(r"(?s)<!--.*?-->", " ", html)
|
||||
html = re.sub(r"(?is)<br\s*/?>", "\n", html)
|
||||
html = re.sub(r"(?is)</(p|div|li|h[1-6]|tr|section|article)>", "\n", html)
|
||||
text = re.sub(r"(?s)<[^>]+>", " ", html)
|
||||
return self._clean_text(unescape(text))
|
||||
|
||||
def _clean_text(self, text: str) -> str:
|
||||
lines = [re.sub(r"\s+", " ", line).strip() for line in text.splitlines()]
|
||||
return "\n".join(line for line in lines if line)
|
||||
|
|
@ -15,6 +15,15 @@ def test_model_router_keeps_sol_for_planning_and_qwen_for_work() -> None:
|
|||
assert router.route("review") == "qwen"
|
||||
|
||||
|
||||
def test_model_router_uses_env_model_policy(monkeypatch) -> None:
|
||||
monkeypatch.setenv("ARTIFEX_PLANNING_MODEL", "terra")
|
||||
monkeypatch.setenv("ARTIFEX_CODING_MODEL", "qwen")
|
||||
router = ModelRouter()
|
||||
|
||||
assert router.route("planning") == "terra"
|
||||
assert router.route("coding") == "qwen"
|
||||
|
||||
|
||||
def test_langgraph_runtime_is_hidden_behind_runtime_interface() -> None:
|
||||
runtime = LangGraphRuntime()
|
||||
|
||||
|
|
|
|||
|
|
@ -71,10 +71,44 @@ class ResearchProvider(ModelProvider):
|
|||
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()
|
||||
|
|
@ -145,6 +179,20 @@ def test_ic_scoring_decision_capability_gap_and_memo_artifact() -> None:
|
|||
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())
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue