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:
- Assemble the system prompt = the stage's
.mdrubric + an injected block of reference material.stages/references.yamldeclares what gets injected where — e.g.taxonomygets the sector examples,gate_scopegets the country + excluded-vertical lists,score_teamgets the top-institutions lists. A declared-but-missing file fails loud. - 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. -
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 -
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:
extract— reads the deck/link/name and builds theStartupProfile. It also judges whether the input is a pitch deck.- 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.) - Sender overrides applied in code (see the mental model).
find_domain— only if extract found no domain. It web-searches for the official site, and the answer is written back ontoprofile.domains. It runs here, before dedup, precisely because dedup keys on the domain.- dedup — the duplicate check (see below); may stop with
ALREADY_LISTED. taxonomy— appendssector_i/sector_iionto the profile.gate_scope— the single gate; decides Sourced vs Passed (see below).lp_match— runs on both arms (see LP matching).- The branch:
- Passed →
passed_notewrites a one-paragraph reason; no score. - Sourced → the four scorers run,
aggregatefolds them into a composite + band (pure math), thenbriefingwrites the investment memo.
- Passed →
- 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:
- Match by domain — reliable, so it's tried first.
- 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 ofconfig.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:
reference/taxonomy/disambiguation.md— plain-language guidance on telling close sectors apart.reference/taxonomy/examples.yaml— worked examples that anchor the judgment.
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:
passorpass_stretch→ Sourced — fully scored, with a briefing.fail→ Passed — 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:
- the full briefing note in Attio (the memo a U2V team member reads),
- the email digest back to the requester (a few bullets — "what it is, how it scores"), and
- 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
statusresolves a title to an option id, anactor-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_stageis written; on an update it's omitted to preserve the human-set funnel stage (unless a stage was force-set).lp_relevanceauto-creates missing options; the curatedpassed_reasondoes 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_intakeinpipeline.pyfrom extract to the Attio write." - "Show me how
llm.pyinjects the strict enums and forces theemit_resulttool." - "Explain the
score_team.mdrubric — its constructs and floors." - "How exactly does
_render_rosterfold the LP theses into the cached prompt?"