u2v-dealflow-sample¶
🔎 Retriever · autonomous / scheduled (not dispatcher-routed) · read-only
u2v-dealflow-sample is the one automation nobody emails. On its own timer, every two months, it reads
the U2V Dealflow list, deterministically picks 5 representative deals per sector, folds each
deal's fit note into a fixed 15-card PowerPoint template — text only, the design untouched — and emails
the finished .pptx to the team. It keeps the data-room sample deck fresh with zero manual effort. This
page goes deep on how. Use the table of contents on the right to jump around.
At a glance¶
flowchart TD
A["cron-job.org pings on the 1st of the month"] --> G{Odd month, or forced?}
G -->|no| Gx["Skip — a ~10s no-op (bi-monthly)"]
G -->|yes| B["Read every deal from the U2V Dealflow list"]
B --> C["Deterministically select 5 per bucket:<br/>AI & Novel Computing · Industrial Tech · CleanTech"]
C --> D["For each pick: fold its U2V fit note into<br/>card text (1 Sonnet call per pick)"]
D --> E["Fill the fixed 15-card .pptx template<br/>(text only, geometry-mapped)"]
E --> F["Email the deck to the team from klaus@u2v.vc"]
F --> H["Commit last_featured.json so the next<br/>edition doesn't repeat picks"]
The spine is run in
pipeline.py:
read → select → fold → fill → email → save state. It's live — the cron pinger is wired and the
deck ships to the team on the bi-monthly cadence — and offline-tested (35 tests, including a golden
end-to-end run over a fake workspace). Every heavy dependency (httpx, anthropic, python-pptx) is
imported lazily inside the step that needs it, so the whole suite runs offline with no keys.
The mental model: a deterministic picker with one narrow model call¶
Almost everything this tool does is plain, reproducible code. The two hard-looking parts — which deals to feature and where the text goes on the slide — are both solved deterministically, by algorithms, not by a model. The model appears exactly once per card, and its job is deliberately tiny: condense an assessment U2V already made into four short boxes. It never decides what to feature and never re-judges a company. Hold that split in mind and the rest of the page is just detail:
- Selection is an algorithm (
select.py) — eligibility gates, a ranking cascade, and a graph-matching step for variety. Same data in → same five deals out. - Layout is an algorithm (
template_map.py+render.py) — the template is sacred; only its text is replaced, and the mapping from card to text box is reconstructed from geometry. - The model only folds text (
content.py) — one Sonnet call per pick, tightly length-budgeted, and it degrades to the plain description rather than ever aborting the run.
The trigger: bi-monthly from one monthly ping¶
This tool has no inbox, so its schedule lives entirely in a GitHub Action
(/.github/workflows/dealflow-sample.yml
at the monorepo root). The design avoids a subtle trap: GitHub's native schedule: cron is unreliable,
so — like the dispatcher — an external cron-job.org job fires the Action
via the workflow_dispatch REST endpoint on the 1st of every month. But we want bi-monthly, and
running two triggers would risk two different decks landing in the same month.
The fix is one trigger and a gate inside the workflow — a tiny shell step that only lets the real job proceed on odd months (or when a human forces it):
# a human can force an off-cadence build from the Actions tab
if [ "${{ github.event.inputs.force }}" = "true" ]; then
echo "forced — building"; echo "run=true" >> "$GITHUB_OUTPUT"; exit 0
fi
m=$(date -u +%-m)
if [ $(( m % 2 )) -eq 1 ]; then
echo "odd month ($m) — building"; echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "even month ($m) — skipping"; echo "run=false" >> "$GITHUB_OUTPUT"
fi
So a monthly ping becomes a bi-monthly build (Jan / Mar / May / … ); an even-month ping is a ~10-second
no-op. An off-cycle edition is a one-click workflow_dispatch with force = true. The setup is
described on Structure & orchestration.
What it reads from Attio¶
Like its read-only siblings, no Attio slug is hardcoded in src/ — every read goes through
reference/attio_read_map.yaml
(loaded into a typed ReadMap, fieldmap.py) and a read-only client (attio.py) that uses only GET
plus the read-only .../entries/query and /v2/notes endpoints. From the U2V Dealflow list it
reads each deal's deal_stage, u2v_sector_i (the coarse bucket), u2v_sector_ii (the ~37 sub-sector
tags), claude_score, and created_at; from each deal's parent company record it reads name,
domains, and description. The fit note is matched the same way the other tools match it — by the
stable title stem ^u2v\W*fit / ^u2v\W*score, most-recent wins — so intake's note-title drift doesn't
break it. A preflight step reconciles the read map against live Attio and fails the run on a real
schema error (a missing stage label is a warning, not a stop).
Selecting the deals — a deterministic algorithm¶
This is the heart of the tool, and it's pure code (select.py) — no I/O, no model, fully reproducible.
Buckets are processed in config order, and a deal is assigned to the first bucket it qualifies for
and never reused across buckets.
Eligibility — a deal must pass every gate: it's in the bucket's Sector-I; it has a
claude_score strictly above the threshold (2.8 — a blank score never qualifies); its deal_stage
is one of the eligible stages; it wasn't in the previous edition (if exclude_previous is on); and it
passes a stage-scoped recency rule — advanced-stage deals (IC, Due Diligence, Second/First Call)
are proof-of-currency and exempt, while early tiers (Sourced, Watchlist, Passed) must be within
90 days.
Ranking — a cascade tuple, sorted ascending, so the best deal is first:
# select.py — stage tier, then higher score, then more recent, then name (a stable tie-break)
return (cfg.selection.stage_rank(d.deal_stage), -(d.claude_score or 0.0), -epoch, d.company.lower())
The final company.lower() term is deliberate: it makes ties break the same way every run, so the
selection is deterministic down to the ordering.
Maximising sub-sector variety (the clever part)¶
Ranking alone would happily fill a bucket with five near-identical companies. To show range, the
picker treats "5 deals, each showing a distinct u2v_sector_ii tag" as a bipartite maximum-matching
problem (deals ↔ tags, solved with augmenting paths). The feasibility check is a one-liner:
# select.py — can every chosen deal be assigned its own distinct sub-sector tag?
def _all_distinct_feasible(cand_sets):
return len(_max_matching(cand_sets)) == len(cand_sets)
Selection is then two passes: pass 1 walks deals in rank order and greedily admits a deal only if the whole chosen set stays "all-distinct feasible" — a transversal-matroid greedy, which is provably the optimal highest-priority set that can each get a distinct tag. Pass 2 fills any remaining slots by pure rank, accepting that a repeat is now unavoidable. A final matching then assigns each chosen deal the tag it will actually display, maximising the number of distinct tags on screen.
It fails loud, never silently. If a bucket can't clear five deals, or a sub-sector had to repeat, that's recorded as a warning — e.g. "AI & Novel Compute: only 3 deal(s) cleared the bar (need 5) — 2 card(s) left blank." — and those warnings surface in both the run log and the email body. The short card slots are rendered blank (see below); nothing stale leaks through.
Freshness — one-edition memory¶
To avoid repeating last time's picks, the tool keeps a tiny state file
(state/last_featured.json)
listing the entry-ids it featured. exclude_previous feeds that set into eligibility, so next edition
skips exactly those deals. State is written only on a real send (a --dry-run never touches it), and
the Action commits it back to the repo (as a bot, with [skip ci]) so the memory persists between
scheduled runs. A recent fix (62301df) matters here: a brand-new last_featured.json is untracked,
and git diff ignores untracked files, so the CI step now git adds before checking for changes —
otherwise the very first edition would never commit its freshness state.
The one model call — folding, not judging¶
For each pick, content.py makes a single call to Claude (claude-sonnet-4-6) that folds the deal's
internal U2V fit note into the four card fields (a short description, traction & tech, team, and the
U2V-fit line). The rubric — a U2V-team-editable file, stages/card_content.md — is explicit that the
model must condense the existing assessment, not form a new one: neutral register, no marketing
adjectives, and every internal scoring artefact (numbers, bands, section headers) stripped out. The
company name and the [ tag ] are set deterministically, never by the model.
Two things make it robust:
- Sentence-aware length budgeting. Each field has a character cap tuned to the real box size, and
the trimmer (
_trim) cuts back to the last complete sentence within the cap — never a mid-sentence fragment. It even carries a ~60-entry abbreviation set so "Dr. Felix" is never truncated to "Dr.". Any trim raises a flag in the log. - Degrade, never abort. No fit note → fold from the company description; no note and no
description → a sparse-but-valid card; a model error → fall back to the description. A hiccup on one
card never sinks the edition. (A keyless
descriptionmode skips the model entirely — that's how the tests run.)
The template & the geometry map (the intricate part)¶
The .pptx in templates/ is the single source of truth for design — the tool only ever opens
it and writes a fresh output file. The challenge is knowing which of the ~135 text boxes is "card 3's
team box". The naive answer — the nth shape, or a fixed shape id — is a trap: PowerPoint reorders
shapes and reassigns every shape id on save, so any order- or id-based map would silently break the
first time a team member edits the template.
So template_map.py keys on the one thing an edit preserves: each box's absolute position
(top, left) on the slide. It clusters boxes into columns, detects the flush-left section headers by
geometry, matches headers to buckets in reading order (so a header can be freely renamed), chunks
each column into 9-box card clusters, and assigns fields by "the body box sits directly below its
label". It fails loud on any structural surprise — wrong header count, a card that isn't 9 boxes,
two same-kind labels — so a mis-edited template can't produce a silently wrong deck. The result is
reference/card_map.yaml, rebuilt with the map-template command after any template edit.
Filling (render.py) is then deterministic and text-only. The trick to preserving the design exactly is
to clone the template paragraph's own run formatting rather than write new formatting:
# render.py — carry the template's run formatting (rPr) verbatim; only .text changes
proto = deepcopy(existing[0])
_reduce_to_one_run(proto) # keep run[0]'s formatting, drop extra runs/breaks
for p in existing:
txBody.remove(p)
for line in paragraphs:
newp = deepcopy(proto)
_set_run_text(newp, line) # only the text is new
txBody.append(newp)
A test asserts the first run's formatting XML is byte-identical after fill. Section headers and field
labels aren't in the card map at all, so they're never touched. And build_pptx iterates the map, not
the cards — so any slot a shortfall left empty is actively blanked (all fields set to ""), which is
exactly how a bucket that couldn't fill five renders in the deck: clean empty cards, never stale text
from a previous edition.
Sending the deck¶
Delivery (gmail.py) is a trimmed, send-only copy of the dispatcher's Gmail client — it never reads
the inbox — reusing the same scoped gmail.send credentials. It sends a fresh email (no
In-Reply-To, unlike a dispatcher reply) as klaus@u2v.vc, with the .pptx attached. The body
(compose_email_body) is an intro, an optional bold review-note CTA, a per-bucket table of contents of
what's in the deck, and — crucially — a Notes section listing every selection warning. So a shortfall
or a repeated sub-sector is visible to the team in the email, not buried in a CI log.
Limitations¶
- Only scored deals are eligible. A promising company that was never run through dealflow, or that scored at or below 2.8, simply won't appear — the sample is drawn strictly from what's been assessed.
- It shows breadth, bounded by the data. The diversity step maximises distinct sub-sectors, but if a bucket genuinely lacks five varied, recent, well-scored deals, it says so (blank cards + a note) rather than padding. The deck is only as rich as the pipeline behind it.
- The budgets and tag aliases are tuned to the current template. The per-field character caps and the
long→short sub-sector label aliases are hand-fitted to the current box sizes; a material redesign of
the template means re-tuning those in
config.yaml(and rerunningmap-template). - It's only as good as the fit notes. The card text folds each deal's existing note; a thin or missing note yields a sparser card. Same lesson as everywhere in the system — clean Attio data in, clean deck out.
Where things live¶
| Path | Role |
|---|---|
src/u2v_sample/pipeline.py |
Orchestrates one edition (run) + builds the email body |
src/u2v_sample/select.py |
Deterministic 5-per-bucket selection + the Sector-II diversity matching |
src/u2v_sample/content.py |
The one Sonnet call per pick; sentence-aware length budgeting; fallbacks |
src/u2v_sample/template_map.py |
Geometry-based mapping of the 15 cards; fail-loud; map-template |
src/u2v_sample/render.py |
Text-only fill of the .pptx, cloning the template's formatting; blanks empty slots |
src/u2v_sample/attio.py |
Read-only Attio client + preflight + fit-note fetch |
src/u2v_sample/gmail.py |
Send-only Gmail client — a fresh email from klaus@u2v.vc, not a reply |
src/u2v_sample/state.py |
Reads/writes last_featured.json (one-edition freshness) |
templates/Dealflow_Sample_Template.pptx |
The deck design — the source of truth |
reference/attio_read_map.yaml |
The Attio field/slug map — read-only |
reference/card_map.yaml |
The 15-card → shape wiring (rebuilt with map-template) |
stages/card_content.md |
The fold rubric — U2V-team-editable |
config.yaml |
U2V-team-editable knobs (see below) |
Knobs a U2V team member can safely change in config.yaml: the sector buckets (and their order);
the selection score_threshold, recency_days, which stages qualify, and the exclude_previous
toggle; the card length budgets and the sector_ii_display short-label aliases; and the email
recipients, subject, intro, and review-note. The template design lives in the .pptx; the Attio
slugs live in the read map.
How to run it¶
cd u2v-dealflow-sample
PYTHONPATH=src .venv/bin/python -m u2v_sample.cli preflight # check the read map against live Attio
PYTHONPATH=src .venv/bin/python -m u2v_sample.cli run --dry-run # build the deck, don't email or save state
PYTHONPATH=src .venv/bin/python -m u2v_sample.cli map-template # rebuild the card map after editing the .pptx
In production nobody runs it by hand — the GitHub Action does, on the bi-monthly cadence above.
Go deeper
The geometry mapping and the diversity matching are the two most intricate pieces. Open the repo in Claude Code and ask, for example:
- "Explain how
template_map.pyfigures out which text box is which card without relying on shape order." - "Walk me through the two-pass selection in
select.pyand why it uses bipartite matching for sub-sector variety." - "Show me how
render.pypreserves the template's fonts and colours while replacing only the text."