Skip to content

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 &quot;new&quot;<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_relevance tag decides everything. A startup appears for an LP if, and only if, its lp_relevance tag 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 Assessor is 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: description skips 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 × ", the date, and "What looks for" pulled from their thesis), then one thin-black-boxed section per startup: name in green, web link, sector tags, "What they do" (the description), and "Our assessment" (the folded paragraph). It's an editable .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_relevance for 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-weeks by 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_relevance being 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.py paints 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.py from a missing note to the placeholder."