u2v-lp-share¶
🔎 Retriever · dispatcher-routed · read-only
For a chosen LP, u2v-lp-share turns what's already in Attio into two ready-to-send documents. This
page goes deep on how it does that — and the one idea that makes the whole thing simple to reason
about. Use the table of contents on the right to jump around.
At a glance¶
Name an LP and lp-share reads the U2V Dealflow list in Attio and produces:
- an Excel snapshot of every startup ever flagged for that LP, and
- a branded, editable Word "what's new" briefing covering only the deals added recently.
flowchart TD
A["u2v_share.cli --lp "Precitec""] --> B[Load config + the Attio read map]
B --> C[Build a read-only Attio client]
C --> D{{Preflight: does the read map still match live Attio?}}
D -->|drift| Dx[warn / exit loud]
D -->|ok| E["Resolve the LP against the roster<br/>(fuzzy: 'Trumpf' → 'TRUMPF GmbH + Co. KG')"]
E --> F["Fetch every deal flagged for this LP<br/>(gate = the lp_relevance tag)"]
F --> G["Mark which deals are "new"<br/>(added inside the rolling window)"]
G --> H["For each NEW deal: fold its U2V fit note<br/>into one neutral paragraph (1 Sonnet call)"]
H --> I["Render Excel — ALL flagged deals"]
H --> J["Render Word — only NEW deals"]
I --> K["Print {ok, message, files:[xlsx, docx]}"]
J --> K
It's feature-complete and live-verified end to end, with 34 offline tests (including a golden
run over recorded Attio payloads). The orchestration lives in
cli.py
(cmd_lp); every heavy dependency (httpx, openpyxl, python-docx, anthropic) is imported
lazily inside the step that needs it, which is what lets the whole suite run offline with no keys.
The mental model: Attio is the log, this is a renderer¶
Hold onto this one idea and the rest of the page falls into place: lp-share stores nothing and writes nothing back. Attio is the append-only log of everything the fund has flagged; lp-share is a pure renderer that reads that log and regenerates two documents on demand. Run it twice and you get the same result — there's no state of its own to drift.
That single idea has two consequences worth stating up front:
- The
lp_relevancetag decides everything. A startup appears for an LP if, and only if, itslp_relevancetag names that LP. There is no separate list, no database — the tag is the source of truth (see below). - Two documents, two scopes. The Excel is the complete picture — every deal ever flagged for the LP. The Word doc is the delta — only what's new since the rolling window opened. Same data, two lenses.
Who gets included — the lp_relevance tag¶
Inclusion is driven purely by the lp_relevance multiselect on each dealflow deal. lp-share reads
all dealflow entries and keeps the ones whose lp_relevance names the LP — a client-side filter
(Attio's list API has no parent-record filter slug for this, so it reads then filters):
# attio.py — keep a deal iff its lp_relevance tag contains this LP's name
for entry, company in self._iter_dealflow():
if lp_name in _select_titles(entry_values.get(lp_slug)):
yield to_shared_deal(entry, company)
Funnel stage is irrelevant to inclusion — a Passed or Lost deal still shows if it's flagged (its stage only changes how it's labelled).
How an LP becomes shareable in the first place. The list of shareable LPs — the "roster" — comes
from the Soft LPs U2V list, keeping only entries with dealflow_sharing = "Yes"; each LP's
thesis is read from its strategic_interests field (used for the "what they look for" section).
So making an LP shareable is the same two-field step described on the
What it does today page:
set dealflow_sharing to "Yes" and fill strategic_interests. Flagging which deals they see is done
on each deal's lp_relevance (by dealflow or by hand in Attio).
The LP name you pass is resolved fuzzily against that roster (_resolve_lp): an exact match wins;
otherwise a unique substring / token-subset resolves ("Trumpf" → "TRUMPF GmbH + Co. KG"); an ambiguous
name or an unknown one exits with a helpful message rather than guessing.
"New" is a stateless window¶
Whether a deal is "new" is decided by one comparison — its created_at against a rolling cutoff — and
nothing else:
# selection.py — the entire "is this new?" rule
cutoff = since if since is not None else today - timedelta(weeks=window_weeks)
for d in deals:
d.is_new = d.created_at is not None and d.created_at >= cutoff
The default window is 3 weeks (config.yaml), overridable per run with --window-weeks N or a
hard --since YYYY-MM-DD. Only the Word doc is limited to is_new; the Excel always shows
everything.
Why stateless matters. There is no "last shared" ledger. An earlier version did keep one (a
state.py + share_log.json set-diff); it was deliberately removed. Without it, the tool is
idempotent: run it twice, or have two teammates run it an hour apart, and everyone gets the
identical document — never a confusing empty "second" one because someone already "used up" the new
deals. The trade-off is honest: "new" means added recently, not not-yet-sent-to-this-LP.
The assessment fold — the one LLM call¶
For each new deal, lp-share makes a single call to Claude (claude-sonnet-4-6) to fold that
startup's internal U2V fit note into one neutral, LP-facing paragraph — deliberately
stripping every internal artefact: the numeric scores, the weightings, the band verdict, the section
headers. What the LP reads is prose about the company, not a peek at our scoring machinery. This is the
only model call in the whole tool; everything else is deterministic.
Three things make it robust, all in
assess.py:
- Cached per deal. A startup flagged for several LPs is folded once — and because a single
Assessoris shared across--all-lps, that cache spans the whole run. - Graceful fallbacks. No fit note → fall back to the company description → then to a visible placeholder. A model error → fall back to the description. "A model hiccup must never abort a share."
- A keyless mode. Setting
assessment.mode: descriptionskips the model entirely and just uses the description — fully reproducible, no API key, which is how the tests run.
The folded paragraph only ever lands in the editable Word doc — it is never written back to Attio.
Finding the right note (title drift)¶
To fetch that fit note, lp-share matches the note's title stem rather than an exact string —
^u2v\W*fit or ^u2v\W*score, case-insensitive — and takes the most recent match. That's
deliberate: the intake pipeline's note title has drifted over time ("U2V Fit Analysis — X", "U2V Fit
Note", "U2V Fit", the legacy "U2V Score — X"), and matching the stable stem survives all of them. If
no note is found, that's surfaced loudly in the log — so the silent failure mode ("assessment
quietly became the plain description") can't slip through unnoticed.
The two documents¶
Excel (excel.py)
— the full snapshot of every flagged deal. Default columns: Company · Vertical · Sub-Vertical · Funnel
Stage · Web Domain · Description (plus the internal score column only if score.show is on). It's
brand-styled: Inter throughout, electric-green headers in a black box, a frozen header row + first
column, clickable domain links, an auto-filter.
Word (docx_report.py)
— the "what's new" briefing, only the new deals. A branded cover ("U2V × .docx on purpose, not a locked PDF — a U2V team member tweaks the
assessments, then Save-As-PDF to send. All of it — colours, fonts, logo, the "CONFIDENTIAL" footer, the
claim lines — is driven from the branding block in config.yaml, so the look changes without
touching code.
Read map & preflight¶
No Attio field name or LP name is hardcoded in src/; every read goes through
reference/attio_read_map.yaml.
Before generating anything, a preflight step diffs that map against live Attio — checking the deal
stages and funding-round options still resolve and the roster is reachable — and exits loud if the
schema has drifted. A renamed field fails fast with a clear message instead of silently producing an
empty or wrong report.
Limitations¶
- Read-only — it never writes back. A deal that isn't flagged with
lp_relevancefor the LP simply won't appear, however relevant it seems; and any edits you make to the Word doc are yours to send, never synced back to Attio. - "New" ≠ "not yet sent". Because it's stateless, "new" means added within the window. If you
need to re-share and skip what you sent last week, adjust
--since/--window-weeksby hand. - The brand needs the Inter fonts installed. openpyxl and python-docx can't embed fonts, so Word and Excel only render in the U2V brand on a machine with the Inter family installed.
- PDF is a manual step. The tool emits an editable
.docx; locking it to a branded PDF is a Save-As by the sender (by design — so assessments can be tweaked first). - It's only as good as the data. The assessment quality depends on the fit note in Attio, and
inclusion depends on
lp_relevancebeing set — clean data in, clean pack out.
Where things live¶
| Path | Role |
|---|---|
src/u2v_share/cli.py |
Entry point + orchestration; LP resolution; the --json envelope |
src/u2v_share/attio.py |
Read-only Attio client, roster/deal/note fetch, the preflight check |
src/u2v_share/selection.py |
The stateless "is this deal new?" window |
src/u2v_share/assess.py |
The one Sonnet call that folds the fit note; caching + fallbacks |
src/u2v_share/mapping.py |
Raw Attio entry → a SharedDeal (funnel-stage labels, etc.) |
src/u2v_share/excel.py |
Excel snapshot (all flagged deals) |
src/u2v_share/docx_report.py |
Branded editable Word "what's new" (new deals only) |
config.yaml |
U2V-team-editable knobs (see below) |
reference/attio_read_map.yaml |
The Attio field/slug map — read-only |
templates/assets/ |
Logo + the Inter font family used in the documents |
skill/share-dealflow/ |
A plain-language Claude skill that launches the CLI |
Knobs a U2V team member can safely change in config.yaml: the selection.window_weeks "new"
window; the funnel_display labels (how raw stages read to an LP); include.exclude_stages (e.g. hide
Lost); the Excel columns and styling; assessment.mode (llm vs. keyless description), model
and max_words; the Word sections toggles; and the whole branding block. The roster and each LP's
thesis live in Attio, not here.
How to run it¶
cd u2v-lp-share
PYTHONPATH=src .venv/bin/python -m u2v_share.cli --lp "Precitec"
# --all-lps for the whole roster · --list-lps to see it · --window-weeks N to widen the window
Outputs land in out/<LP>/ as the dated .xlsx and .docx. In production it's invoked by the
dispatcher's lp_share workflow — an email like "What's new for Trumpf?" routes here
with --lp "Trumpf" --json, and the two files come back as reply attachments.
Go deeper
Open the repo in Claude Code and ask, for example:
- "Walk me through how
docx_report.pypaints the full-page branded background so it survives PDF export." - "Show me the fuzzy LP resolver in
_resolve_lp— when does it refuse to guess?" - "Trace the assessment fallback chain in
assess.pyfrom a missing note to the placeholder."