← back to Rentv Sheet Enrich
WARM-RESUME.md
89 lines
# RENTV contact-LinkedIn enrichment — WARM resume procedure (TK-10533)
The one thin field left on the master "Unique Contacts (all tabs)" tab (gid 3823360)
is **LinkedIn (Contact)**. Everything else (websites 3230, phones 770, company-LI,
emails @ 92% reachability) is DONE. This is a warmth-prioritized long-tail grind,
resumed by the doing-board NUDGE loop.
## Each resume session
1. **Rebuild warm batches from LIVE missing** (self-correcting — already-filled contacts
drop out automatically, so no done-tracking needed):
```
cd ~/Projects/rentv-sheet-enrich
python3 - <<'PY'
import lib,json,re,os,csv
def norm(s): return re.sub(r'[^a-z0-9]','',(s or '').lower())
warm={}
for row in csv.DictReader(open('rentv_CRE_worklist.csv')):
try: w=float(row['Warmth'] or 0)
except: w=0
warm[(norm(row['Contact Name']),norm(row['Company']))]=w
warm.setdefault((norm(row['Contact Name']),''),max(w,warm.get((norm(row['Contact Name']),''),0)))
tok=lib.access_token();GID=3823360
title={s["properties"]["sheetId"]:s["properties"]["title"] for s in lib.get_meta(tok)["sheets"]}[GID]
rows=lib.read_tab(tok,title);hdr=rows[0]
H=lambda l:next((i for i in range(len(hdr)) if (hdr[i] if i<len(hdr) else "").strip()==l),None)
NAME,CO,LIC=H("Contact Name"),H("Company/Venue"),H("LinkedIn (Contact)")
C=lambda r,i:(rows[r][i] if i is not None and r<len(rows) and i<len(rows[r]) else "").strip()
def garbage(nm):
t=nm.split()
return (len(t)<2 or not re.match(r'^[A-Za-z]',nm) or bool(re.search(r'\d',nm))
or t[0].lower() in {'acq','vp','asst','jr','sr','mr','ms','dr','the','email'})
miss=[];seen=set()
for r in range(1,len(rows)):
nm=C(r,NAME)
if not nm or C(r,LIC) or garbage(nm): continue
co=C(r,CO);k=(norm(nm),norm(co))
if k in seen: continue
seen.add(k);miss.append((warm.get(k,warm.get((norm(nm),''),0)),{"company":co,"name":nm}))
miss.sort(key=lambda x:-x[0])
os.makedirs("data/warm_batches",exist_ok=True)
for f in os.listdir("data/warm_batches"): os.remove("data/warm_batches/"+f)
for n,i in enumerate(range(0,len(miss),30)):
json.dump({"targets":[m[1] for m in miss[i:i+30]]},open(f"data/warm_batches/wb_{n:03d}.json","w"))
print("missing",len(miss),"-> batches",-(-len(miss)//30))
PY
```
2. **Fan out web-researcher subagents, 2 CONCURRENT MAX** (the server rate-limits bursts of
3+ — 10-burst and 3-burst both got throttled; 1–2 run clean). Prompt = the per-contact
procedure in CONTACT_INSTRUCTIONS.md, WebSearch-only ($0), blank-beats-wrong,
company+name copied VERBATIM. Each writes `/tmp/ctresult_wb_NNN.json`.
3. **Write back centrally & serially** (race-safe vs claude-rentv's concurrent yoloforever
writer on the same sheet): `python3 write_by_contact.py 3823360 /tmp/ctresult_wb_NNN.json`
4. Commit + `tk log` the coverage delta.
## Yield reality (measured 2026-08-21)
- Warmth-first hot batches: **~65–73% LinkedIn yield** (major firms: Newmark/Matthews/JLL/etc).
- Alphabetical/cool tail: **0–17%** (garbage names, common names @ obscure firms) — do LAST.
- ⇒ Always warmth-order; the residual cool tail is diminishing-returns.
## Guards
- ADD-only, only-empty-cells, name-matched (row-shift immune). Green (#cbe6b4) auto-stamp.
- NO fabrication: only /in/ slugs that literally appear in WebSearch results.
- Sheet writes for THIS enrichment are Steve-authorized (reversible, internal, ADD-only).
RENTV *email SENDING* stays permanently blocked — this ticket never sends.
## Rate-limit note (2026-08-21)
The server tightens through a session: 10-burst → 3-burst → even 2-burst eventually throttle.
Fallback is **1 agent at a time**, and include in the prompt: "if a WebSearch returns a
rate-limit error, wait briefly and retry before giving up; WRITE the output file even if some
searches failed." That recovered wb_010 after it died mid-run.
## slug-source gate (Cody FIX-FIRST, 2026-08-22) — MANDATORY
The fabrication firewall is now enforced in CODE, not just the prompt. Every worker result
entry MUST carry `slug_source`: one of `"live_title"` (the /in/ slug appeared verbatim in a
result title naming the person), `"live_snippet"` (appeared in body text), or `"inferred"`
(model derived it from metadata / a company-slug pattern — NOT read in a result).
`write_by_contact.py` now REJECTS any contact_li whose `slug_source` is `"inferred"` and
reports the count as `rejected_inferred` — the exact class the manual Cody gate used to catch
by hand (e.g. Bryan Shaffer/GSP `/in/ireto`). Add this line to every worker prompt:
> For each match also set `slug_source` to "live_title"/"live_snippet"/"inferred". If the slug did
> not literally appear in a WebSearch result, mark it "inferred" (it will be auto-rejected) — never
> pass an inferred slug as if confirmed.
Legacy batches without the field still write (backward-compatible), but all NEW runs tag it.
## Cody gate = a NAMED step, not a convention (Cody FIX-FIRST, 2026-08-22)
Each yoloforever cycle MUST end its enrichment pass with a contrarian (Cody) gate over the
cycle result + an empirical read-back (coverage delta on the live sheet), before logging +
rescheduling. Do not skip it because "the writer looks fine" — the gate is the process backstop.