Skip to content

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 &amp; 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 description mode 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 rerunning map-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.py figures out which text box is which card without relying on shape order."
  • "Walk me through the two-pass selection in select.py and why it uses bipartite matching for sub-sector variety."
  • "Show me how render.py preserves the template's fonts and colours while replacing only the text."