Skip to content

u2v-dealflow

✍️ Writer · dispatcher-routed · writes to Attio

This is the flagship automation and the deepest engine in the repo — so this page goes further than the others. Read it top to bottom and you'll understand how a pitch deck becomes a scored, classified, LP-matched record in Attio, and why each decision along the way was built the way it was. Use the table of contents on the right to jump around.

At a glance

Give u2v-dealflow one startup — as a pitch deck (PDF), a link, or just a name — and Claude reads it, places it in U2V's sector taxonomy, decides whether it's in scope, scores it across four dimensions, matches it to relevant LPs, writes a briefing, and records the deal in the U2V Dealflow list in Attio. Every deal it sees gets written — the brain should hold a record of everything that crossed the desk.

flowchart TD
    A["u2v intake-pdf --owner <email> <deck|link|name>"] --> B["extract → StartupProfile"]
    B --> T{"is it a pitch deck?"}
    T -->|no, and a file was sent| NAD["NOT_A_DECK — stop, nothing written"]
    T -->|yes| OV["apply sender overrides<br/>(owner · source · round · raise)"]
    OV --> FD["find_domain (only if no domain yet)"]
    FD --> DD{"already on the list<br/>AND evaluated?"}
    DD -->|yes| AL["ALREADY_LISTED — skip, no re-score"]
    DD -->|no| TX["taxonomy → sector_i / sector_ii"]
    TX --> G{{"gate_scope"}}
    G -->|fail| P["passed_note → PASSED<br/>(written, no score)"]
    G -->|pass / pass_stretch| S["4 scorers → aggregate → briefing → SOURCED<br/>(written + scored)"]
    P --> LP["lp_match runs on BOTH arms"]
    S --> LP
    LP --> W[("write to Attio:<br/>company → list entry → note → deck")]

In daily use through the shared inbox. 184 offline tests — the largest suite in the repo. The spine lives in src/u2v/; the judgment lives in stages/ and reference/.


The mental model: one object that grows

The single most useful thing to hold in your head is this: there is one central object, StartupProfile, that is born early and then grows as it moves down the pipeline. Almost everything downstream reasons over that object, never the raw deck again.

Here's its life:

flowchart LR
    E["extract<br/>reads the deck once"] -->|creates| P0["StartupProfile"]
    P0 --> P1["+ source_kind<br/>+ sender overrides"]
    P1 --> P2["+ domains<br/>(find_domain)"]
    P2 --> P3["+ sector_i / sector_ii<br/>(taxonomy)"]
    P3 --> DS["every later stage reads the<br/>grown profile, returns its OWN result"]
    DS --> RR["RunRecord<br/>(everything gathered for the audit log)"]

It's born in extract. A common misconception is that the profile is assembled field-by-field before any AI runs. It isn't. The deck (or link, or name) is read once, by the extract stage, and that call is what creates the StartupProfile. Reading the deck once, up front, and then having every later stage reason over the extracted fields — never the raw PDF — is a deliberate choice (StartupProfile is described in the code as the pipeline's "bottleneck object").

Extraction is the one knowledge-gathering step — everything downstream reuses it

This is the key idea. Whether the input is a deck, a website, or a web search on the company, extract is where all the knowledge is captured — the team, the technology, the traction, the market, everything the deck or site says — and folded into the one StartupProfile. From then on, nothing re-reads the source. Taxonomy, the scope gate, all four scorers, and LP-matching each reason over that same captured block of knowledge. So the quality of everything downstream is set here: a rich extraction makes for good scoring; a thin one (e.g. a name-only intake with no deck) makes every later judgment more preliminary. Get extraction right and the rest follows.

Then the sender's facts are layered on — and they win. Immediately after extraction, the pipeline applies the fields the dispatcher passed in from the email (owner, deal source, funding round, target raise). The rule is sender-stated facts beat web-inferred ones — because a live round size or a named source is proprietary knowledge the web simply can't have:

# pipeline.py — right after extract
if owner_override is not None:          # the emailing teammate is the owner (authoritative)
    profile.owner = owner_override
if deal_source_override:                # source named in the email wins; empty → keep extract's
    profile.deal_source = deal_source_override
round_override = _normalize_funding_round(funding_round_override, fm)
if round_override:                      # a stated round wins — but only if it's a valid option
    profile.funding_round = round_override
if target_raise_override is not None:   # a stated raise wins over research
    profile.target_raise_eur = target_raise_override

Where those overrides come from

They're the --owner, --deal-source, --funding-round, --target-raise flags on the CLI. The dispatcher extracts them from the forwarding email and passes them per-startup — so "what the email said" flows straight into the profile as authoritative fact.

It keeps growing. Two later stages write back onto the profile: find_domain fills domains if the deck had none, and taxonomy appends sector_i / sector_ii. From that point every stage that runs sees those sectors, because each stage is handed a fresh snapshot of the profile:

def _profile_ctx(profile):
    return {"profile": profile.model_dump()}   # the profile, re-serialized at each stage boundary

Every other stage returns its own typed result. Scoring, the scope gate, LP-match and the briefing don't mutate the profile — each returns its own Pydantic model (ScopeResult, ScorerOutput, LPMatch, NoteOutput…), which the pipeline threads forward as needed. At the very end, the profile plus all these results are gathered into a single RunRecord — the audit object saved to runs/ for every run.

So the shape to remember: one profile grows (extract → overrides → domain → sectors); the judgment stages hang their own typed outputs off it; everything lands in a RunRecord.


Two layers: the spine and the judgment

Everything in the project is one of two kinds of thing, and keeping them apart is the core design decision of the whole repo (see Design principles → data vs. logic):

The spine (engineers) The judgment (U2V-team-editable)
src/u2v/ — Python: control flow, Attio I/O, validation, cost stages/*.md — the rubric for each AI step
Deterministic, tested, rarely changes stages/references.yaml — which reference files feed which stage
Never hardcodes an Attio field name or a score threshold reference/** — sector examples, scope lists, team signals
config.yaml — weights, models, thresholds, toggles

What the stages/*.md files actually are

Each file in stages/ is a system prompt — the instructions for one AI step, in plain language a U2V team member can edit. There's one per stage: extract.md, find_domain.md, taxonomy.md, gate_scope.md, the four score_*.md, lp_match.md, briefing.md, passed_note.md, plus email_digest.md and roster.md for the email/roster paths. Change the wording of a rubric and you change the behaviour — with no code edit.

How a rubric becomes an AI call

The engine that turns a rubric into a result is llm.py. For any stage it does four things:

  1. Assemble the system prompt = the stage's .md rubric + an injected block of reference material. stages/references.yaml declares what gets injected where — e.g. taxonomy gets the sector examples, gate_scope gets the country + excluded-vertical lists, score_team gets the top-institutions lists. A declared-but-missing file fails loud.
  2. Force structured output. The model must answer by calling one tool, emit_result, whose schema is generated from the stage's Pydantic model — so the reply is always well-formed data, never prose to parse.
  3. Inject strict enums so the model can only return real, in-vocabulary values. The allowed values come from the field map, not the code:

    # llm.py — the model literally cannot return a sector that isn't in our taxonomy
    elif stage == "taxonomy":
        self._set_items_enum(props, "sector_i",  self._closed_vocab("u2v_sector_i"))
        self._set_items_enum(props, "sector_ii", self._closed_vocab("u2v_sector_ii"))
    elif stage == "lp_match":
        self._set_items_enum(props, "lps", [e["name"] for e in roster])   # only real LPs
    
  4. Validate the returned data back into the Pydantic model — a final guarantee it's well-shaped and in-vocab before the pipeline uses it.

Which model, how hard it thinks, and whether it caches are all per-stage settings in config.yaml (routing, reasoning, caching) — never hardcoded. The judgment stages run on Opus with extended thinking; the mechanical ones on lighter models.


The pipeline, stage by stage

The spine — pipeline.py (run_intake) — is pure control flow. In order:

  1. extract — reads the deck/link/name and builds the StartupProfile. It also judges whether the input is a pitch deck.
  2. Triage. If a file was sent but it isn't a pitch deck, stop immediately with NOT_A_DECK — nothing is written. (A link/name intake can't be "not a deck", so this only guards file uploads.)
  3. Sender overrides applied in code (see the mental model).
  4. find_domainonly if extract found no domain. It web-searches for the official site, and the answer is written back onto profile.domains. It runs here, before dedup, precisely because dedup keys on the domain.
  5. dedup — the duplicate check (see below); may stop with ALREADY_LISTED.
  6. taxonomy — appends sector_i / sector_ii onto the profile.
  7. gate_scope — the single gate; decides Sourced vs Passed (see below).
  8. lp_match — runs on both arms (see LP matching).
  9. The branch:
    • Passedpassed_note writes a one-paragraph reason; no score.
    • Sourced → the four scorers run, aggregate folds them into a composite + band (pure math), then briefing writes the investment memo.
  10. Assemble → validate → write to Attio (see Writing to Attio).

When the web is searched

Web search is expensive and slow, so it's switched on at exactly three moments, each gated by its own config.yaml toggle under enrichment:

Stage Searches when… Why
extract the intake is a link or name (no deck attached) there's no deck to read, so it researches the company
find_domain extract found no domain to locate the official website before dedup
score_market always (in production) to cross-check market size and competition with live evidence

A deck intake never searches during extract — the deck is the source of truth. The gating logic is one small function:

# llm.py
def _web_search(self, stage, attachments=None):
    if stage == "score_market": return bool(enr.get("market_search", False))
    if stage == "find_domain":  return bool(enr.get("domain_search", False))
    if stage == "extract":      return bool(enr.get("extract_search", True)) and not attachments
    return False

Why some stages quietly switch to a softer prompt

Normally the engine forces the model to call emit_result. But a forced tool call is incompatible with web search (the model needs to be free to call the search tool first) and with adaptive thinking. So on those stages the engine drops to a free tool_choice and appends an instruction — "you MUST finish by calling emit_result exactly once." Same guarantee, reached a different way.


The duplicate check & when we re-score

Before doing any expensive work, the pipeline asks Attio: do we already know this company? The check is_known tries two things, in order:

  1. Match by domain — reliable, so it's tried first.
  2. Fall back to a conservative name match — only if there's no domain. It's deliberately strict: a bare one-word name never merges into a longer one, to avoid a false merge (folding two different companies together, which is worse than missing a duplicate). A name-only match is flagged in the logs for a human to sanity-check.

Then comes the decision that trips people up: being a duplicate doesn't automatically mean "skip". The deal is skipped (ALREADY_LISTED, no re-score, no write) only if it's already on the list and already evaluated:

# pipeline.py — skip only if on the list AND (already scored OR at a settled stage)
if dedup.known and (dedup.scored or _is_settled_stage(dedup.stage, config.bypass_stages)):
    return RunRecord(..., outcome=Outcome.ALREADY_LISTED)
  • dedup.scored — it already has a real composite score.
  • _is_settled_stage — its funnel stage is one of config.bypass_stages (IC, Invested, Passed, Lost, Due Diligence, First/Second Call). A deal a human has already moved forward shouldn't be re-judged by a machine.

If the company is known but the deal is unscored and still early, it is re-scored — and written as an update in place. Crucially, on an update the deal_stage is left untouched, so a refresh can never drag a "First Call" deal back to "Sourced". The only exception is when a human explicitly forced a stage (below).


Placing a startup in the taxonomy

Right after dedup, the taxonomy stage assigns the startup a Sector I (the broad vertical, e.g. AI & Novel Compute) and one or more Sector II sub-sectors (e.g. Edge AI). This isn't cosmetic: the sectors are appended onto the profile and then used everywhere downstream — they frame the scope gate, they give LP-matching the context it needs, and they're what the sample deck buckets on. Because the sector map is the fund's own view of the market, getting it right — and keeping it clean — is exactly the "high-quality brain" idea from the vision.

The model can only pick real sectors. The list of valid sectors is a controlled vocabulary: it's injected into the tool schema as a strict enum, so the model literally cannot invent a sector.

# llm.py — the allowed sectors come from the field map, not from code
self._set_items_enum(props, "sector_i",  self._closed_vocab("u2v_sector_i"))
self._set_items_enum(props, "sector_ii", self._closed_vocab("u2v_sector_ii"))

Where that vocabulary lives — and how to change it. The sectors themselves are defined in the Attio field map, reference/attio_field_map.yaml (mirroring the options on the Attio list). To add or rename a sector, you edit it there (and in Attio) — not in the Python. No sector name is ever hardcoded in src/.

How the model is taught to classify well. A closed list tells the model what it may pick; two reference files teach it how to choose:

These are wired to the stage by stages/references.yaml, which declares "the taxonomy prompt gets these two files appended." At run time, llm.py reads that manifest and folds the files into the taxonomy system prompt. So there are two edit points, both data, both in the repo: change the vocabulary in the field map, change the judgment in the taxonomy reference files. Editing either changes behaviour with no code change — this is the data-vs-logic seam in action, and it's the same pattern every stage with reference material uses (the scope gate reads the country and excluded-vertical lists; score_team reads the top-institutions lists).

The single gate: Sourced vs Passed

There is exactly one gate, gate_scope. It returns one of three verdicts, and the routing is simple:

  • pass or pass_stretchSourced — fully scored, with a briefing.
  • failPassed — written with a one-line reason, but no score.

An earlier design had two gates — the first one is gone

A previous version had a first gate that discarded out-of-scope startups entirely. It was removed. Now everything Klaus receives is written; the sole gate only decides how it's recorded. The U2V team wanted a record of everything that crossed the desk.

Two more things shape the branch:

  • The score band is a label, not a decision. The four scorers produce a composite that's bucketed into a band ("recommend deep dive" / "monitor" / "pass"). That band is advice text on the record — it never decides Sourced vs Passed. Only the gate does. Keeping those separate stops the score from quietly gatekeeping the funnel.
  • A forced funnel stage overrides the gate. If the email named an active stage (say "First Call"), the deal takes the full Sourced path even if the gate would have Passed it; naming "Passed"/"Lost" forces the Passed path. Absent an override, the gate decides.

How scoring works

Only Sourced deals are scored (a fail at the gate skips straight to the passed note). Scoring is deliberately split into four independent scorers, each its own AI call on the strongest model (Opus, with extended thinking), each returning a single number from 1.0 to 4.0 plus its rationale and the evidence behind it:

Scorer Rubric Judges
score_team stages/score_team.md founders, prior track record, founder–market fit
score_technology stages/score_technology.md the technical edge / defensibility
score_business_model stages/score_business_model.md how it makes money and scales
score_market stages/score_market.md market size and competition (uses web search to cross-check)

Running them separately — rather than asking one call for four numbers — keeps each judgment focused and its reasoning auditable.

Two levels of weighting

This is the part worth internalising: the emphasis is applied twice, at two different layers.

1. Inside each scorer (judgment, in the rubric). Every score_*.md rubric breaks its dimension into sub-constructs with their own weights and floors, and the model folds all of that into the one 1–4 number it emits. For example, the team score is a weighted blend of prestige, problem-fit, coverage, market-fit and execution — with a floor like "team cannot exceed 3.0 if problem-fit is below 3." So the nuance is already baked into each dimension's score. Change this by editing the rubric — it's data.

2. Across the four scores (arithmetic, in code). The four numbers are then combined by aggregate.py — pure, deterministic math, no model — into one composite, using the weights in config.yaml:

# config.yaml — scoring.weights (must sum to 1.0; team carries the most weight)
team:            0.389
technology:      0.222
business_model:  0.222
market:          0.167
composite = Σ (weightᵢ × scoreᵢ)          # a plain weighted mean, in aggregate.py
band      = band_for(composite)           # a label: "recommend deep dive" | "monitor" | "pass"

So the model does the judging (each 1–4, with its internal floors); the code does the arithmetic (the weighted mean and the band) — a clean split you can test and reason about. The band cutoffs also live in config.yaml (scoring.bands).

The band is a label, not a gate

It's easy to assume the composite decides whether a deal is pursued. It doesn't. The band is advice written onto the record — the scope gate alone decides Sourced vs Passed. A low composite still gets fully written as a Sourced deal; it just reads "pass" as its recommendation for a human to weigh.

Before the write, validate.py enforces the contract from the gate section: a Sourced deal must carry a composite in 1.0–4.0; a Passed deal must not carry one.

LP matching in one shot

This is one of the defining architectural decisions, so it's worth understanding precisely.

The goal: figure out which of U2V's dealflow-sharing LPs a startup is relevant to. The naive approach would be to ask the model once per LP. Instead, it's a single API call that compares the one startup against every relevant LP at once.

First, the roster is fetched deterministically from the Soft LPs U2V list — keeping only LPs with dealflow_sharing = "Yes", and pulling each one's strategic_interests (their thesis) plus optional example matches:

# pipeline.py — fetched once, before the Sourced/Passed branch, reused on both arms
roster = attio.fetch_lp_roster()                       # every LP with dealflow_sharing == "Yes"
lp_ctx = {**_profile_ctx(profile), "lp_roster": [e.model_dump() for e in roster]}
lp = runner.run("lp_match", LPMatch, lp_ctx)

Then every LP's thesis is rendered into the system prompt's cached prefix — not the per-startup part of the prompt — so a whole batch of startups in one dispatcher pass reuses the same (cached) roster block:

# llm.py — the roster becomes part of the cacheable prefix, the profile stays the dynamic tail
if "lp_roster" in context:
    roster = context.pop("lp_roster")
    system = f"{system}\n\n{_render_roster(roster)}"   # all LP theses, one block

Two properties fall out of this design:

  • One call, all LPs. The model reads every thesis and the one startup profile together, and returns the LPs it fits — cheaper and more consistent than N per-LP calls, and (thanks to prompt caching) nearly free to repeat across a batch.
  • It can only return real LPs. The output field is pinned to a strict enum of the fetched LP names (the enum-injection from layer two) — an out-of-roster LP is literally unemittable.

lp_match runs on both the Sourced and Passed arms. Its picks are then unioned with any LPs the sender flagged in the email, and written to the deal's lp_relevance multiselect in Attio (auto-creating an option if a newly-relevant LP isn't there yet).


How the fit note is written

The fit note is the human-facing pay-off — the memo a U2V team member actually reads on the Attio record. Its shape depends on the outcome, and there are two writers:

Sourced → the briefing stage. This is the one stage that sees everything at once. Where earlier stages each get a focused slice, briefing is handed the full picture — the profile, all four scores with their rationales, the composite and band, the scope verdict, and the matched LPs — and synthesises them into the investment memo:

# pipeline.py — the briefing is the synthesis step; it receives the whole run so far
note = runner.run("briefing", NoteOutput, {
    **_profile_ctx(profile),                       # the grown startup profile
    "scores":    [s.model_dump() for s in scores], # all four dimensions + rationale + evidence
    "aggregate": aggregate_result.model_dump(),    # composite + band
    "scope":     scope.model_dump(),               # the gate's reasoning
    "lp":        lp.model_dump(),                   # the matched LPs
}).body

It runs on Sonnet with high effort (a writing task, not a fine judgment), and its rubric stages/briefing.md governs the memo's tone and structure. Because it's fed the rationales, the memo explains the score rather than just restating the number — it's the place all the upstream judgment is woven into one narrative.

Passed → the passed_note stage. A Passed deal doesn't need a full memo, so a lighter, cheaper call (Haiku) writes a single honest paragraph on why it's out of scope and picks a passed_reason from a controlled list (rubric: stages/passed_note.md). Same idea, right-sized.

Either way the result is written to Attio as a markdown note on the company record (titled "U2V Score — …"). It's the note the read-only tools later reuse: u2v-lp-share folds it into an LP briefing, and u2v-dealflow-sample condenses it onto a slide.

The email reply: a condensed digest

There's one last step, and it only happens on the email path. Once everything has succeeded — the deal scored, the note written — the person who emailed Klaus gets a reply. But they don't want the full memo in their inbox; they want a quick "here's what this is and how it looks." So a small email_digest stage (stages/email_digest.md, on a light model) condenses the fit note one level further — into a few tight bullets — and that digest becomes the message field of the {ok, message, files} envelope:

# pipeline.py — on the email path, condense the note into the reply text
digest = runner.run("email_digest", EmailDigest, {**_profile_ctx(profile), "note": note})
# → returned as the envelope's "message", which the dispatcher emails back to the sender

The dispatcher relays that message straight back as the reply to whoever forwarded the deal (CC'ing the team when it's a new record). So the same assessment exists at three lengths, each shorter than the last:

  1. the full briefing note in Attio (the memo a U2V team member reads),
  2. the email digest back to the requester (a few bullets — "what it is, how it scores"), and
  3. later, if the deal is featured, an even tighter sample-deck card (u2v-dealflow-sample).

Rosters skip the digest on purpose

A single email intake gets this digest. A roster entry doesn't — batch runs use --no-digest and the sender gets one summary email with a CSV for the whole list instead (handled by the dispatcher's roster queue).

Writing to Attio

Only two outcomes ever write — SOURCED and PASSED; NOT_A_DECK and ALREADY_LISTED return before this point. The write in attio.py happens in a fixed order:

# attio.py — write(): the order matters
company_id = dedup.company_id or self._assert_company(payload)   # 1. upsert the company
# 2. the deal: PATCH the existing list entry, or POST a new one
# 3. the briefing note (markdown)
# 4. the deck PDF, uploaded to the company record (best-effort)

A few decisions worth knowing:

  • What the deal entry carries. Beyond the score and sectors, the list entry records the funnel stage, the funding round and target raise (fund size), the deal source, the matched LPs (lp_relevance), and the owner. The funding round and raise are exactly the fields the email can override — a sender-stated round or raise is proprietary and wins over anything extraction inferred — and the round size is also what the scope gate reads to judge fit. So "fill in the round and the raise" is a real, load-bearing part of the record, not metadata.
  • Company upsert. If there's a domain, it's a PUT keyed on the domain (so re-runs don't duplicate the company); with no domain it falls back to a plain create.
  • Values are enveloped by type. Each Attio field type has its own shape — a status resolves a title to an option id, an actor-reference (the owner) resolves an email to a workspace-member id, and so on. Those title→id lookups are cached per run.
  • Create vs. update. On a new deal, deal_stage is written; on an update it's omitted to preserve the human-set funnel stage (unless a stage was force-set). lp_relevance auto-creates missing options; the curated passed_reason does not (it's best-effort, and the prose note carries the reason regardless).

Before any of this runs, validate.py checks the payload against the controlled vocabulary and enforces the core contract — fail loud, and write nothing on an invalid payload:

# validate.py — the outcome/score contract
if payload.outcome == Outcome.SOURCED:
    # a Sourced deal MUST carry a composite score in 1.0–4.0
elif payload.outcome == Outcome.PASSED and deal.claude_score is not None:
    # a Passed deal must NOT carry a score

Finally, every run is priced. cost.py turns each stage's token usage into a per-run CostSummary (with a cache-hit ratio), which is attached to the RunRecord and folded into the --json reply — so the dispatcher can report what a deal cost.


Limits of the model

No engine like this is magic, and it's worth being honest about where the edges are. These follow directly from the deterministic vs. non-deterministic boundary: the deterministic parts are dependable; the model-driven parts are powerful but bounded.

It can only fetch certain kinds of link. When a deal arrives as a link rather than a deck, the model can read a normal web page — a company website or a LinkedIn page — and research from there. What it cannot read is a file sitting behind a file-share or e-sign link: Google Drive, Dropbox, DocuSign, WeTransfer and the like. Those URLs don't return the document to the model; at best it falls back to guessing from the link text or the company name, which is unstable and often wrong.

If the deck lives in Drive / Dropbox / DocuSign — download it and send the real PDF

The single most important operating rule: when a pitch deck is shared as a file-share link, don't forward the link to Klaus — download the deck and attach the actual PDF. A real PDF is read in full; a Drive/DocuSign link is not, and the deal will be assessed on almost nothing.

Its output isn't bit-for-bit repeatable. Because the judgment stages are model calls, the same deck can come back with a slightly different score or wording on a re-run. That's expected — it's why the score is advice a human acts on, why the vocabulary is locked down with strict enums, and why the scope gate (not the score) makes the routing decision. The system is built to be stable enough to trust, not deterministic.

It's only as good as what it's given. Two inputs set the ceiling on quality: the source material (a thin deck or a name-only intake yields a thin, preliminary assessment — the profile can only carry what extraction could find) and the data it reads back from Attio (LP matching is only as sharp as the LPs' strategic_interests, and taxonomy only as clean as the sector map). This is the flip side of the "high-quality brain" bet in the vision: keep Attio clean and the decks real, and the engine rewards you; feed it noise, and it can only do so much.

Where things live

Path Role
src/u2v/pipeline.py The spine — run_intake: object lifecycle, overrides, branching, write
src/u2v/llm.py Turns a rubric into an AI call: prompt assembly, forced tool, enum injection, caching
src/u2v/attio.py Dedup + the ordered write (company → entry → note → deck)
src/u2v/aggregate.py Deterministic composite score + band
src/u2v/validate.py Pre-write contract (Sourced needs a score; Passed must not have one)
src/u2v/cost.py Per-run token→cost telemetry
src/u2v/models.py StartupProfile + every stage's typed output + RunRecord
stages/*.md The rubrics — one system prompt per stage
stages/references.yaml Which reference/** files feed which stage's prompt
reference/attio_field_map.yaml The Attio field/slug map + controlled vocabularies
reference/ Scope lists (gate/), taxonomy examples, team-signal lists
config.yaml Weights, per-stage model routing, thinking effort, caching, web-search toggles
runs/ A saved RunRecord for every run

Knobs a non-engineer can change in config.yaml: the scoring weights (team is weighted highest — the exact split is scoring.weights) and band cutoffs; the scope gate's round-size band; per-stage model routing and thinking effort; the enrichment web-search toggles; and a per-deal cost ceiling. The real judgment lives in stages/*.md and reference/**.

Status & how to run it

In daily use, validated end-to-end on real decks. 184 offline tests.

cd u2v-dealflow
PYTHONPATH=src .venv/bin/python -m u2v.cli intake-pdf --owner benedikt@u2v.vc deck.pdf --json
PYTHONPATH=src .venv/bin/python -m u2v.cli intake --owner benedikt@u2v.vc "Acme Robotics"   # by name/link

Go deeper

This page is the map; the code is the territory. Open the repo in Claude Code and ask, for example:

  • "Walk me through run_intake in pipeline.py from extract to the Attio write."
  • "Show me how llm.py injects the strict enums and forces the emit_result tool."
  • "Explain the score_team.md rubric — its constructs and floors."
  • "How exactly does _render_roster fold the LP theses into the cached prompt?"