Code & Snippets

Company Scoring Algorithm.

FastAPI service that scores a company 1–10 against thesis fit, investability, and investor quality — with web research and webhook output.

Sourcing
Python · 470 lines
C.1

Download

Grab the source file. Drop it into your service, set the required environment variables, and deploy.

Download score_company.pyPython · 470 lines
C.2

How it works

The gist

Hand it a company. It researches that company online, scores it against three things you care about, blends them into one number out of 10, and ships the result off to a webhook. Works on a single company or a whole batch.

The flow

  1. Company comes in — name, description, and (optional) funding and investor data.
  2. Two non-AI gates run first. Is it in an allowed country (US, Western Europe, a few allies)? Do we even have financial data to work with? Fail either and it stops immediately — out of jurisdiction, or flagged as needing more data. This keeps you from spending AI calls on companies you can't act on.
  3. Web lookup. It pulls real context from the open web, because a one-line description rarely tells you what a company actually does.
  4. Three judges score it, each 1–4, with a written reason:
    • Proximity to Thesis — does this fit what we invest in? (Half the score.)
    • Investability — can it absorb a check our size right now, given how much it's raised?
    • Investor Quality — are credible, top-tier investors already in?
  5. Blended score out of 10. Thesis fit is weighted 50%; the two money questions split the other 50%.
  6. Ship the result. Final score and every judge's reasoning are posted to a webhook.

Smart touches worth knowing

  • Dead-end detection without an AI call. Already acquired, gone public, or shut down? Auto-flagged. Missing data defaults sensibly instead of crashing.
  • Nothing is a black box. Every score arrives with a couple of sentences explaining why, so you can sanity-check it.
  • It's a triage engine. Point it at a pile of companies and it tells you which ones deserve a human's attention, and why.

How to run it

Built as a FastAPI service designed to run on Railway (or any Python host). Triggered via HTTP — single company or batch. Requires OPENAI_API_KEY and SERPER_API_KEY as environment variables. Posts results to the webhook URL you configure per request.

C.3

Source

Full source, exactly as shipped. The download above is byte-identical.

score_company.pyPython
"""
Company Scoring Algorithm
Scores companies on a 1-4 scale across multiple criteria.
Designed to run on Railway, triggered via API or batch.
"""

import os
import json
import uvicorn
import httpx
from datetime import datetime, timezone
from fastapi import FastAPI, HTTPException, BackgroundTasks, Header
from pydantic import BaseModel, Field
from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
SERPER_API_KEY = os.environ.get("SERPER_API_KEY", "")
MODEL = "gpt-5-mini"


def search_company(company_name: str, gicp_description: str) -> str:
    """Search Serper for company context. Returns raw JSON response as string."""
    if not SERPER_API_KEY:
        return "NO_SERPER_KEY"
    try:
        query = f"{company_name} {gicp_description}"
        with httpx.Client(timeout=10) as http:
            resp = http.post(
                "https://google.serper.dev/search",
                headers={"X-API-KEY": SERPER_API_KEY, "Content-Type": "application/json"},
                json={"q": query, "num": 8},
            )
        return resp.text
    except Exception as e:
        return f"SEARCH_ERROR: {str(e)}"

# ── Thesis ─────────────────────────────────────────────────────────
THESIS = """The Firm is a growth equity firm investing $25–100M+ in advanced technology companies at inflection points of growth. We back emerging leaders building foundational, engineering-driven technologies critical to Western resilience, economic expansion, and national security. We invest globally, typically as a meaningful minority partner, often taking board seats, and focus on scaling durable category leaders rather than early-stage science projects or pure software businesses.

Our thesis centers on advanced, defensible deep technology with hard science at its core — companies with proprietary IP, engineering depth, and structural barriers to entry. We prioritize technologies that strengthen Western competitiveness across strategic domains, especially where dual-use (commercial and defense/government) applications reinforce durability and demand. We prefer to invest in large markets with significant total addressable market potential.

Core coverage areas:
- Quantum & Advanced Communications: quantum computing hardware/systems, quantum software/algorithms, quantum sensing, quantum communications, cybersecurity.
- Semiconductors: AI accelerators and novel compute architectures (edge, chiplets), memory/storage innovations (HBM, NVM, compute-in-memory), interconnect/networking (optical, CXL, silicon photonics), semiconductor manufacturing equipment/materials, semiconductor software and hardware/software co-design (EDA adjacencies, compilers, co-optimization).
- Defense & Space: autonomous systems (air, land, sea, subsurface), mission software and defense AI (C2, ISR, autonomy stacks), space systems (launch, satellites, SDA, ground infrastructure), resilient PNT/comms and electronic warfare, weapons and advanced effects (kinetic/non-kinetic), cyber/digital defense infrastructure.
- Energy: nuclear fission, nuclear fusion, grid/energy infrastructure resilience (storage, transmission, controls), next-generation energy systems (geothermal, long-duration storage, novel generation).
- Resource Security & Abundance: critical minerals/materials (rare earths, battery metals, processing), resource discovery/extraction technologies, food systems technology, water systems technology.
- 4th Industrial Revolution: robotics and industrial autonomy, advanced manufacturing systems and digital factories, advanced materials (structural, functional, metamaterials).

We do NOT focus on: consumer applications, generic SaaS, marketplaces, or software wrappers lacking defensible technical differentiation."""

# ── Rubrics ───────────────────────────────────────────────────────────
RUBRICS = {
    "thesis_fit": {
        "name": "Proximity to Thesis",
        "prompt": f"""You are a senior investment analyst at the Firm. Your ONLY job is to assess how closely a company aligns with the Firm's investment thesis. You are NOT evaluating the company as an investment — only its relevance to the thesis.

THESIS:
{THESIS}

SCORING:
1 - Not a fit: Consumer, generic SaaS, marketplace, or non-technical business. No meaningful connection to any core coverage area.

2 - Possible fit: Meets only ONE of the following three criteria. This includes companies that enable or support a coverage area without being deep tech themselves (e.g., software tooling for AI workloads, cloud infrastructure built on third-party hardware, data platforms for defense customers):
  (a) The company's primary product maps directly to a named sub-sector in our core coverage areas. The company must be building technology that advances the capabilities of the sub-sector, not merely providing generic business tools or workflow software to companies in that industry. Specialized, technically differentiated software can qualify when it is a named sub-sector or directly enables one.
  (b) There is evidence of proprietary IP or engineering-driven barriers to entry (not just domain expertise, data moats, or software wrappers).
  (c) The company operates in a domain with inherent dual-use or national security relevance. This is determined by the nature of the domain itself, not by whether the company has existing government or defense customers. For example, space hardware, autonomous systems, critical minerals, energy infrastructure, cybersecurity, and semiconductor technologies are inherently strategic domains — a company building in these areas satisfies this criterion regardless of its current customer base.

3 - Good fit: Meets at least TWO of the three criteria (a), (b), and (c) above. The company is clearly building advanced technology with meaningful strategic relevance, but may not be category-defining or may be one of several credible players in its sector.

4 - Fantastic fit: Meets ALL THREE criteria and the company is building foundational technology with the potential to become a durable category leader in its domain. This is reserved for companies where sector alignment, technical depth, strategic importance, and dual-use potential all converge.

PREFERENCE: We have deep networks in government and defense. Companies with government adjacency — meaning their technology has clear applications for government or defense end-users, even if they don't serve them today — are especially interesting to us because we can leverage our relationships to support their growth. Note this in your rationale when present, but do not use it to change the score. It is not a requirement at any score level.

You will be given the company name, description, and action description (a verb/action-focused summary of what the company does). Use these to assess relevance.

CRITICAL: Understanding what a company does from a short description alone is hard. You have THREE signals to work with — use ALL of them together to build the most accurate picture of what this company actually does before scoring:
1. The company description (may be vague or generic)
2. The action description (verb/action-focused, more precise)
3. Web search results (real-world context about the company's technology and market)

Base your assessment ONLY on these provided signals. Do NOT use any prior knowledge you may have about the company name — company names can be shared by multiple unrelated entities.

Respond ONLY with valid JSON: {{"score": <1-4>, "rationale": "<2-3 sentences>"}}""",
        "requires_pitchbook": False,
    },
    "investability": {
        "name": "Investability",
        "prompt": """You are an investment analyst at a growth-stage fund that writes $20-50M checks, typically at Series A-E.

Score whether this company could realistically absorb a $20-50M check in an upcoming round.

IMPORTANT: You will often have incomplete data. That is fine — work with what you have. Use whatever signals are available to make your best judgment. Do NOT penalize a company just because a field says "N/A". If you only have one data point, use it. If you have several, triangulate. Be scrappy.

PRIMARY SIGNAL — Total Amount Raised:
This is the most important datapoint. Score primarily based on this when available:
- Sweet spot: $15M–$150M raised → strong signal for a 4
- Acceptable: $5M–$15M or $150M–$500M raised → score a 3
- Outside <$5M / $500M+ → likely a 1 (too early or too late)

CRITICAL RULES FOR RESOLVING CONFLICTS:
- ALWAYS check the Last Funding Type field before scoring. If it says "Merger/Acquisition", "Out of Business", or "IPO", automatically score the company a 1 — they are either no longer independently investable or too early for a $20-50M check. This overrides all other signals.
- When total funding data is available, score based on that. Secondary signals can only move your score down by 1 point maximum from what total funding alone would suggest. The ONLY secondary signals strong enough to trigger a downgrade are: (1) a disqualifying Last Funding Type (auto-1 per the rule above), or (2) total funding is in the sweet spot but the company has raised over $250M in a single round, suggesting late-stage dynamics. A low valuation alone is never a strong enough contradictory signal to downgrade.
- If total funding falls in the $5M–$15M or $150M-$500M acceptable band, score a 3. Do NOT score a 1 or 2 just because valuation is low or the last round was a seed. A 1 requires total funding clearly outside the acceptable range (<$5M or >$500M).

SCORING:
1 - Not investable: The available data clearly points to wrong stage for a $20-50M check.
2 - Possibly investable: Some signals align but meaningful gaps or mixed signals.
3 - Good candidate: Available data is in or near the sweet spot. A $20-50M check likely makes sense.
4 - Ideal candidate: Primary signal is squarely in the sweet spot, with no conflicting secondary signals.

Respond ONLY with valid JSON: {"score": <1-4>, "rationale": "<2-3 sentences>"}""",
        "requires_pitchbook": True,
    },
    "investors": {
        "name": "Investor Quality",
        "prompt": """You are an investment analyst. Evaluate this company's investor base against a curated list of top deep tech investors.

TOP DEEP TECH INVESTORS:
Shield Capital, Standard Investments, Washington Harbour, US Innovative Technology, Protego Ventures, M12 - Microsoft's Venture Fund, Earthrise Ventures, Harpoon, Tamarack, In-Q-Tel, Also Capital, Squadra Ventures, Riot Ventures, Andreessen Horowitz, Summit Partners, Helium-3 Ventures, Alumni Ventures, Prelude Ventures, Construct Capital, Point72 Ventures, 8VC, Touring Capital, Glade Brook Capital Partners, Timescale, America's Frontier Fund, Energy Impact Partners, Solaricap, Sway Ventures, Qualcomm, Not Boring Capital, 8090 Industries, Obvious Ventures, The Engine, Cantos, Silent Ventures, Rhapsody Venture Partners, Valhalla Ventures, Playground Global, Lightspeed Venture Partners, Lux Capital, Undeterred Capital, Founders Fund, Marque.vc, Coatue, Eclipse Ventures, FUSE, Khosla Ventures, Bessemer Venture Partners, Moore Capital Management, Marlinspike, Spacevc, Altimeter Capital, AMD, DCVC, General Catalyst, Breakthrough Energy, Radius Mobility Capital, RTX, Xfund, New Enterprise Associates, J2 Ventures, IAG Capital Partners, Ravelin Capital, CIV, Humba Ventures, Prime Movers Lab, Type One Ventures, MVP Ventures, Decisive Point, XYZ Venture Capital, Insight Partners, Quiet Capital, Greylock, Cottonwood Technology Fund, Anzu Partners, Micron Ventures, Scout Ventures, SOSV, s32, Mubadala, GV, Edge Runner, Radical Ventures, Draper Associates, Calibrate Ventures, Booz Allen Hamilton, Two Sigma Ventures, X the moonshot factory, Waabi, Kleiner Perkins, Porsche Ventures, Fifty Years, NVIDIA, Social Capital, Boost VC, Basis Set Ventures, Sequoia Capital, Caffeinated Capital, Bain Capital Ventures, Gaingels, United States Department of War, Y Combinator, U.S. Department of Energy, Mana Ventures, Plug and Play Tech Center, US National Science Foundation, Overmatch, IPO CLUB, Climate Capital, Liquid 2 Ventures, BoxGroup, Friends & Family Capital, Valor Equity Partners, Lockheed Martin Ventures, Rsquared, National Aeronautics and Space Administration, Champion Hill Ventures, Vannevar Ventures, THE BR-DGE, Soma Capital, 1789 Capital, What If Ventures, WorldQuant Ventures, Beyond Earth Ventures, Osage University Partners, Arpa-E, Capital Factory, TriplePoint Capital, Alpen Capital, Cubit Capital, Republic Capital, Saturn Five, Zillionize, Ripple Impact Investments, Metaplanet Holdings, Open Field Capital, Temasek Holdings, Calm Ventures, Vision Capital Group, MaC Venture Capital, 1517 Fund, Aramco Ventures, EDBI, Gigascale Capital, Other People's Capital, Forward Deployed VC, Space Capital, Impatient Ventures, Lontra Ventures, ARK Ventures, F-Prime Capital, Pax VC, Venrock, Airbus Ventures, StepStone Group

SCORING RULES:
OVERRIDE: If a company has even one investor from this elite tier, it is an automatic 4 regardless of other investor signals: Founders Fund, Washington Harbour, Shield Capital, Andreessen Horowitz, 8VC, Playground Global, Khosla Ventures, Coatue, Atreides, Altimeter Capital, Decisive Point.

4 - Fantastic: Company has 2 or more investors from the list (use fuzzy matching — names may vary slightly, e.g. "a16z" = "Andreessen Horowitz", "NEA" = "New Enterprise Associates", "Microsoft M12" = "M12 - Microsoft's Venture Fund", "NASA" = "National Aeronautics and Space Administration", "NSF" = "US National Science Foundation", "DOE" = "U.S. Department of Energy", "YC" = "Y Combinator").
3 - Good: Company has exactly 1 investor from the list.
2 - Possible: No matches from the list, but investors include legitimate, recognizable VC firms, corporate venture arms, or established institutional investors.
1 - No fit: Investors are mostly angels, unknown entities, accelerators only, or there is no meaningful institutional signal.

Respond ONLY with valid JSON: {"score": <1-4>, "rationale": "<2-3 sentences identifying any top-100 matches by name>"}""",
        "requires_pitchbook": True,
    },
}


# ── Scoring Function ─────────────────────────────────────────────────

ALLOWED_COUNTRIES = {
    "united states", "canada", "israel", "australia",
    # Western Europe
    "united kingdom", "germany", "france", "netherlands", "belgium",
    "austria", "switzerland", "luxembourg", "liechtenstein", "monaco",
    "italy", "spain", "portugal", "ireland",
    # Nordics
    "sweden", "norway", "denmark", "finland", "iceland",
}


def check_geography(company: dict) -> dict:
    """Binary geography check. Returns score 0 if outside jurisdiction."""
    location = company.get("location", "").strip()
    if not location:
        return {
            "criterion": "Geography",
            "score": 1,
            "rationale": "No location data available. Assuming within jurisdiction.",
            "status": "assumed_pass",
        }
    country = location.rsplit(",", 1)[-1].strip().lower()
    in_jurisdiction = country in ALLOWED_COUNTRIES
    return {
        "criterion": "Geography",
        "score": 1 if in_jurisdiction else 0,
        "rationale": f"Location: {location}. {'Within' if in_jurisdiction else 'Outside'} investment jurisdiction.",
        "status": "scored",
    }
def score_criterion(criterion_key: str, company: dict, web_context: str = "") -> dict:
    """Score a single company on a single criterion."""
    rubric = RUBRICS[criterion_key]

    company_context = f"""Company: {company['name']}
Description: {company['description']}
Action Description: {company['gicp_description']}"""

    # ── Enrich thesis fit with web search ─────────────────────────────
    if criterion_key == "thesis_fit":
        company_context += f"""

Web Search Results (use these to better understand what the company does):
{web_context}"""

    if rubric["requires_pitchbook"]:
        company_context += f"""
Investors: {company.get('investors', 'N/A')}
Total Funding: {company.get('total_funding', 'N/A')}
Last Funding Amount: {company.get('last_funding_amount', 'N/A')}
Last Funding Date: {company.get('last_funding_date', 'N/A')}
Last Funding Type: {company.get('last_funding_type', 'N/A')}
Last Funding Valuation: {company.get('last_funding_valuation', 'N/A')}"""

    try:
        response = client.chat.completions.create(
            model=MODEL,
            messages=[
                {"role": "system", "content": rubric["prompt"]},
                {"role": "user", "content": company_context},
            ],
        )
        raw = response.choices[0].message.content

        # ── Robust JSON parsing with multiple fallbacks ───────────
        # Strip markdown fences
        raw = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()

        # Attempt 1: Direct parse
        result = None
        try:
            result = json.loads(raw)
        except json.JSONDecodeError:
            pass

        # Attempt 2: Extract score and rationale via regex
        if result is None:
            import re
            score_match = re.search(r'"score"\s*:\s*(\d)', raw)
            # Grab rationale between quotes after "rationale":
            rationale_match = re.search(r'"rationale"\s*:\s*"(.*)', raw, re.DOTALL)
            if score_match:
                score_val = int(score_match.group(1))
                if rationale_match:
                    # Take everything after opening quote, strip trailing quote/brace
                    rat = rationale_match.group(1).rstrip().rstrip('}').rstrip().rstrip('"')
                else:
                    rat = "Rationale could not be parsed."
                result = {"score": score_val, "rationale": rat}

        if result is None:
            raise ValueError(f"Could not parse model response: {raw[:300]}")

        return {
            "criterion": rubric["name"],
            "score": int(result["score"]),
            "rationale": result["rationale"],
            "status": "scored",
        }
    except Exception as e:
        return {
            "criterion": rubric["name"],
            "score": None,
            "rationale": f"Error: {str(e)}",
            "status": "error",
        }


def score_company(company: dict) -> dict:
    """
    Score a company across all applicable criteria.

    Required fields: name, description, gicp_description
    Optional (PitchBook): investors, total_funding, last_funding_amount,
                          last_funding_date, last_funding_valuation
    """
    has_financials = any(
        company.get(f)
        for f in ["total_funding", "last_funding_amount", "last_funding_valuation", "last_funding_type"]
    )

    # ── Gate 1: Geography check (binary, no LLM) ────────────────────────
    geo_result = check_geography(company)
    if geo_result["score"] == 0:
        return {
            "id": company.get("id", ""),
            "company": company["name"],
            "outcome": "out_of_jurisdiction",
            "composite_score": 0,
            "rationale": f"Out of jurisdiction. {geo_result['rationale']}",
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }

    # ── Gate 2: Skip entirely if missing financial/investor data ──────
    if not has_financials:
        return {
            "id": company.get("id", ""),
            "company": company["name"],
            "outcome": "needs_data",
            "composite_score": "",
            "rationale": "No access to financial data. Unable to score.",
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }

    # ── Step 1: Web search for thesis context ───────────────────────────
    web_search_results = search_company(company["name"], company.get("gicp_description", ""))

    # ── Step 2: Score thesis fit ────────────────────────────────────────
    thesis_result = score_criterion("thesis_fit", company, web_context=web_search_results)

    # ── Step 3: Score remaining criteria ──────────────────────────────
    scores = [geo_result, thesis_result]

    # Investability — check for auto-score conditions before using LLM
    last_funding_type = (company.get("last_funding_type") or "").strip().lower()
    disqualifying_types = {"merger/acquisition", "out of business", "ipo"}

    if last_funding_type in disqualifying_types:
        scores.append({
            "criterion": "Investability",
            "score": 1,
            "rationale": f"Last funding type is '{company.get('last_funding_type', '')}'. Company is not independently investable.",
            "status": "auto_disqualified",
        })
    elif not company.get("total_funding") or company.get("total_funding", "").strip().upper() in ("", "N/A"):
        scores.append({
            "criterion": "Investability",
            "score": 2,
            "rationale": "No total funding data available. Defaulting to 2/4.",
            "status": "default",
        })
    else:
        scores.append(score_criterion("investability", company))

    # Investors — default to 2 if no investor data, otherwise use LLM
    if company.get("investors"):
        scores.append(score_criterion("investors", company))
    else:
        scores.append({
            "criterion": "Investor Quality",
            "score": 2,
            "rationale": "No investor data available. Defaulting to 2/4.",
            "status": "default",
        })

    # ── Step 4: Compute weighted composite & convert to /10 ─────────
    WEIGHTS = {
        "Proximity to Thesis": 0.50,
        "Investability": 0.25,
        "Investor Quality": 0.25,
    }

    weighted_sum = 0
    weight_total = 0
    for s in scores:
        if s["score"] is not None and s["criterion"] in WEIGHTS:
            weighted_sum += s["score"] * WEIGHTS[s["criterion"]]
            weight_total += WEIGHTS[s["criterion"]]

    if weight_total > 0:
        weighted_composite = weighted_sum / weight_total
        score_out_of_10 = round((weighted_composite / 4) * 10, 2)
    else:
        weighted_composite = None
        score_out_of_10 = None

    # ── Step 4: Build combined rationale ──────────────────────────────
    rationale_parts = []
    for s in scores:
        if s["criterion"] == "Geography":
            continue
        score_val = s["score"] if s["score"] is not None else "N/A"
        rationale_parts.append(f"{s['criterion']} ({score_val}/4): {s['rationale']}")
    combined_rationale = " | ".join(rationale_parts)

    return {
        "id": company.get("id", ""),
        "company": company["name"],
        "outcome": "scored",
        "composite_score": score_out_of_10,
        "rationale": combined_rationale,
        "web_search_results": web_search_results,
        "timestamp": datetime.now(timezone.utc).isoformat(),
    }


# ── FastAPI App ───────────────────────────────────────────────────────
app = FastAPI(title="Company Scoring Algorithm", version="1.0.0")


class CompanyInput(BaseModel):
    id: str = ""
    name: str
    description: str
    gicp_description: str
    location: str = ""
    investors: str = ""
    total_funding: str = ""
    last_funding_amount: str = ""
    last_funding_date: str = ""
    last_funding_type: str = ""
    last_funding_valuation: str = ""


class BatchInput(BaseModel):
    companies: list[CompanyInput]


WEBHOOK_URL = os.environ.get("WEBHOOK_URL", "")
SECRET_API_KEY = os.environ.get("SECRET_API_KEY", "")


def verify_api_key(x_api_key: str = Header()):
    if not SECRET_API_KEY or x_api_key != SECRET_API_KEY:
        raise HTTPException(status_code=401, detail="Invalid or missing API key")


def send_to_webhook(payload: dict):
    """Post scoring results to a configured webhook (set WEBHOOK_URL env var)."""
    if not WEBHOOK_URL:
        return
    try:
        with httpx.Client(timeout=10) as client_http:
            client_http.post(WEBHOOK_URL, json=payload)
    except Exception as e:
        print(f"Webhook delivery failed: {e}")


@app.get("/health")
def health():
    return {"status": "ok"}


@app.post("/score")
def score_single(company: CompanyInput, background_tasks: BackgroundTasks, x_api_key: str = Header()):
    verify_api_key(x_api_key)
    started_at = datetime.now(timezone.utc).isoformat()
    print(f"[STARTED] Scoring {company.name} at {started_at}")

    try:
        result = score_company(company.model_dump())
        background_tasks.add_task(send_to_webhook, result)

        finished_at = datetime.now(timezone.utc).isoformat()
        print(f"[FINISHED] Scoring {company.name} at {finished_at}")

        return {
            "status": "completed",
            "started_at": started_at,
            "finished_at": finished_at,
            "result": result,
        }
    except Exception as e:
        finished_at = datetime.now(timezone.utc).isoformat()
        print(f"[ERROR] Scoring {company.name} at {finished_at}: {e}")
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/score/batch")
def score_batch(batch: BatchInput, background_tasks: BackgroundTasks, x_api_key: str = Header()):
    verify_api_key(x_api_key)
    started_at = datetime.now(timezone.utc).isoformat()
    total = len(batch.companies)
    print(f"[STARTED] Batch scoring {total} companies at {started_at}")

    results = []
    for company in batch.companies:
        try:
            results.append(score_company(company.model_dump()))
        except Exception as e:
            results.append({
                "company": company.name,
                "outcome": "error",
                "error": str(e),
            })

    payload = {"results": results, "total": len(results)}
    background_tasks.add_task(send_to_webhook, payload)

    finished_at = datetime.now(timezone.utc).isoformat()
    print(f"[FINISHED] Batch scoring {total} companies at {finished_at}")

    return {
        "status": "completed",
        "started_at": started_at,
        "finished_at": finished_at,
        "total": total,
        "results": results,
    }


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)