Capture phone (office) + mobile (cell) on card intake; ship v0.1.0:98

Completes business-card contact capture. The transcription prompt now labels
Phone/Mobile/Fax on separate lines, and the extractor maps an office/main number ->
phone and a cell -> mobile, never a fax. Both carry the same digit-in-source
integrity rule as email/LinkedIn: a number is kept only if its digits literally
appear in the source (or, on revise, the instruction) -- never minted. The proposal
card shows Phone + Mobile and they're editable (aliases phone/tel/office, mobile/cell).

Server: _upsert_contact_from_fundraising now accepts contact.phone + contact.mobile
and writes them to the canonical contact record (contact-level, not grid pills),
shipped in s9pk v0.1.0:98. No schema change -- the contacts columns already exist.

41/41 backend suite green + the matrix_intake units; card flow end-to-end is live-smoke.
This commit is contained in:
Keysat
2026-06-20 11:26:39 -05:00
parent 92ab59de4e
commit e824ff2206
12 changed files with 139 additions and 29 deletions
+5 -2
View File
@@ -162,10 +162,13 @@ def build_commit_payload(proposal):
"name": proposal.get("contact_name") or proposal.get("investor_name") or "",
"email": proposal.get("contact_email") or "",
"title": proposal.get("contact_title") or "",
# city + linkedin_url are already honored by the server's contact upsert
# (_upsert_contact_from_fundraising); city also syncs to the grid contact pill.
# city + linkedin_url + phone + mobile are honored by the server's contact upsert
# (_upsert_contact_from_fundraising); city also syncs to the grid contact pill, the
# rest land on the canonical contact record. phone = office/main line, mobile = cell.
"city": proposal.get("city") or "",
"linkedin_url": proposal.get("linkedin_url") or "",
"phone": proposal.get("phone") or "",
"mobile": proposal.get("mobile") or "",
}
note = proposal.get("note") or ""
# The CRM's grid note line uses subject-or-body for its one-line summary, so a non-empty
+28 -3
View File
@@ -26,6 +26,9 @@ SYSTEM = (
' "contact_title": the person\'s role/title if stated, else null.\n'
' "city": the person\'s city or location if stated (e.g. "New York"), else null.\n'
' "linkedin_url": the person\'s LinkedIn URL if explicitly present, else null. Never invent one.\n'
' "phone": the office/main/direct phone number if present (a line labeled Phone/Tel/Office/'
'Direct, or a single unlabeled number); never a fax or a cell. Else null.\n'
' "mobile": the cell/mobile number if present (a line labeled Cell/Mobile); never a fax. Else null.\n'
' "note": any meeting note, context, or next step, else null.\n'
"Use null (not empty string) for anything not present."
)
@@ -56,7 +59,12 @@ _EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
_LINKEDIN_RE = re.compile(r"(?:https?://)?(?:[a-z]{2,3}\.)?linkedin\.com/[A-Za-z0-9_%/\-.]+", re.I)
_VALID_INTENTS = {"new_investor", "meeting_note", "unclear"}
_FIELDS = ("intent", "investor_name", "contact_name", "contact_email", "contact_title",
"city", "linkedin_url", "note")
"city", "linkedin_url", "phone", "mobile", "note")
def _digits(s):
"""Bare digit run of a string (drops spaces/dashes/parens/dots), for phone-integrity checks."""
return re.sub(r"\D", "", str(s or ""))
def _clean(v):
@@ -89,6 +97,15 @@ def normalize(raw, source_text=""):
lm = _LINKEDIN_RE.search(source_text or "")
out["linkedin_url"] = lm.group(0).rstrip(".,;:!?)]}>\"'") if lm else None
# Phone integrity: keep a number (in its printed formatting) only if its digit run actually
# appears in the source — the model must never mint or "complete" a number. phone = the
# office/main line, mobile = the cell; which is which is the model's call (prompted), this
# only validates that the number is real. (≥7 digits avoids matching a stray short run.)
src_digits = _digits(source_text)
for f in ("phone", "mobile"):
d = _digits(out.get(f))
out[f] = out.get(f) if (len(d) >= 7 and d in src_digits) else None
# City is left as a plain extracted field (no source gate): a wrong city is low-harm and the
# human sees it on the card before approving, unlike a wrong email/LinkedIn.
@@ -114,10 +131,10 @@ REVISE_SYSTEM = (
"team member typed. You are given the CURRENT proposal as JSON and an INSTRUCTION. Apply "
"the instruction and reply with ONLY the full revised JSON object, these keys:\n"
' "investor_name", "contact_name", "contact_email", "contact_title", "city", '
'"linkedin_url", "note".\n'
'"linkedin_url", "phone", "mobile", "note".\n'
"Change ONLY what the instruction asks; copy every other field through unchanged. Use null "
"for a field the instruction clears or that is genuinely absent. Never invent an email "
"address or a LinkedIn URL."
"address, a LinkedIn URL, or a phone number."
)
_REVISABLE = ("investor_name", "contact_name", "contact_title", "city", "note")
@@ -143,6 +160,14 @@ def _apply_revision(proposal, model_out, instruction):
lm = _LINKEDIN_RE.search(instruction or "")
if lm:
out["linkedin_url"] = lm.group(0).rstrip(".,;:!?)]}>\"'")
# Phone/mobile too: a revised number is accepted only if its digits appear in the instruction
# (never let the model mint one); otherwise the existing value is kept.
instr_digits = _digits(instruction)
for f in ("phone", "mobile"):
if f in model_out:
cand = _clean(model_out.get(f))
d = _digits(cand)
out[f] = cand if (cand and len(d) >= 7 and d in instr_digits) else out.get(f)
# Don't let a revision strip the proposal down to nothing actionable.
if not out.get("investor_name") and not out.get("contact_name"):
out["investor_name"] = proposal.get("investor_name")
+5 -1
View File
@@ -20,6 +20,8 @@ _EDIT_ALIASES = {
"title": "contact_title", "role": "contact_title",
"city": "city", "location": "city",
"linkedin": "linkedin_url", "linkedin_url": "linkedin_url", "li": "linkedin_url",
"phone": "phone", "tel": "phone", "office": "phone",
"mobile": "mobile", "cell": "mobile",
"note": "note",
}
@@ -29,7 +31,7 @@ _NO = {"no", "n", "cancel", "discard", "reject", "stop", "👎", "❌"}
_NEW = {"new", "none", "new investor", "none of these", "create", "create new", "add new", "neither"}
_CONTENT_FIELDS = ("intent", "investor_name", "contact_name", "contact_email", "contact_title",
"city", "linkedin_url", "note")
"city", "linkedin_url", "phone", "mobile", "note")
class ProposalStore:
@@ -177,6 +179,8 @@ def render(proposal):
("Contact", proposal.get("contact_name")),
("Email", proposal.get("contact_email")),
("Title", proposal.get("contact_title")),
("Phone", proposal.get("phone")),
("Mobile", proposal.get("mobile")),
("City", proposal.get("city")),
("LinkedIn", proposal.get("linkedin_url")),
("Note", proposal.get("note")),
+4 -2
View File
@@ -34,10 +34,12 @@ CARD_SYSTEM = (
"character:\n"
" - Email: check the local part, the @, and the domain separately (transcribe 'mara.com' as "
"'mara.com', never 'marac.com').\n"
" - Phone number(s).\n"
" - Phone, cell/mobile, and fax numbers — keep each on its own labeled line so they aren't "
"confused (put an office/main/direct number on Phone:, a cell/mobile number on Mobile:, and a "
"fax on Fax:).\n"
" - Website / LinkedIn URL.\n"
"Then list, each on its own labeled line and ONLY if present on the card:\n"
" Name: Title: Company: Email: Phone: LinkedIn: City:\n"
" Name: Title: Company: Email: Phone: Mobile: Fax: LinkedIn: City:\n"
"If a character is genuinely ambiguous, give your single best reading — never invent extra "
"characters to fill a gap. If the image is not a readable business card, reply with the single "
"word NONE. Output only the labeled lines, nothing else."
+6 -3
View File
@@ -16,18 +16,21 @@ def test_new_investor_payload():
assert out["create_investor_if_missing"] is True
assert "row_id" not in out
assert out["contact"] == {"name": "Jane Doe", "email": "jane@acme.com", "title": "GP",
"city": "", "linkedin_url": ""}
"city": "", "linkedin_url": "", "phone": "", "mobile": ""}
assert out["body"] == "met at conf"
assert out["source"] == "matrix_intake"
def test_contact_carries_city_and_linkedin_when_present():
def test_contact_carries_card_fields_when_present():
p = {"intent": "new_investor", "investor_name": "Acme Capital", "contact_name": "Jane Doe",
"contact_email": "jane@acme.com", "city": "New York",
"linkedin_url": "linkedin.com/in/janedoe", "note": "met at conf"}
"linkedin_url": "linkedin.com/in/janedoe", "phone": "212-555-0100",
"mobile": "917-555-0199", "note": "met at conf"}
out = crm_client.build_commit_payload(p)
assert out["contact"]["city"] == "New York"
assert out["contact"]["linkedin_url"] == "linkedin.com/in/janedoe"
assert out["contact"]["phone"] == "212-555-0100" # office/main line
assert out["contact"]["mobile"] == "917-555-0199" # cell
def test_existing_investor_uses_row_id_not_create():
+37
View File
@@ -233,6 +233,43 @@ def test_revise_linkedin_taken_only_from_instruction():
assert r2["contact_title"] == "GP"
def test_phone_and_mobile_kept_when_digits_in_source():
# A card transcription separates Phone/Mobile/Fax; the model maps office->phone, cell->mobile.
src = ("New investor — from a business card:\nName: Daniel Raupp\nCompany: Fortitude\n"
"Phone: 631-474-5610\nFax: 631-474-1806\nCell: 631-922-1195")
p = parse.parse_message(
src,
parse_fn=_stub({"intent": "new_investor", "investor_name": "Fortitude",
"contact_name": "Daniel Raupp", "phone": "631-474-5610",
"mobile": "631-922-1195"}),
)
assert p["phone"] == "631-474-5610"
assert p["mobile"] == "631-922-1195" # the cell, kept in its printed formatting
def test_fabricated_phone_dropped_when_digits_not_in_source():
p = parse.parse_message(
"new prospect Gamma Partners, talked to their GP",
parse_fn=_stub({"intent": "new_investor", "investor_name": "Gamma Partners",
"contact_name": "their GP", "phone": "555-867-5309"}),
)
assert p["phone"] is None # number not in the source → never minted
def test_revise_phone_taken_only_from_instruction():
proposal = {"intent": "new_investor", "investor_name": "Acme", "contact_name": "Jane",
"contact_email": None, "contact_title": None, "city": None, "linkedin_url": None,
"phone": None, "mobile": None, "note": None, "_source_text": "Acme Jane"}
r1 = parse.revise(proposal, "her cell is 917-555-0199",
parse_fn=_stub({"mobile": "917-555-0199"}))
assert r1["mobile"] == "917-555-0199"
# model tries to set a number but the instruction has none → keep existing (None)
r2 = parse.revise(proposal, "set her title to GP",
parse_fn=_stub({"mobile": "000-000-0000", "contact_title": "GP"}))
assert r2["mobile"] is None
assert r2["contact_title"] == "GP"
def test_revise_cannot_empty_the_proposal():
proposal = {"intent": "new_investor", "investor_name": "Acme", "contact_name": "Jane",
"contact_email": None, "contact_title": None, "note": "x", "_source_text": "Acme Jane"}
+12
View File
@@ -68,6 +68,18 @@ def test_render_shows_city_and_linkedin_when_present():
assert "LinkedIn: linkedin.com/in/jane" in out
def test_interpret_edit_phone_and_mobile_aliases():
assert proposals.interpret_reply("phone=212-555-0100") == ("edit", ("phone", "212-555-0100"))
assert proposals.interpret_reply("cell: 917-555-0199") == ("edit", ("mobile", "917-555-0199"))
def test_render_shows_phone_and_mobile_when_present():
p = {**SAMPLE, "phone": "212-555-0100", "mobile": "917-555-0199"}
out = proposals.render(p)
assert "Phone: 212-555-0100" in out
assert "Mobile: 917-555-0199" in out
def test_interpret_unknown():
assert proposals.interpret_reply("maybe later")[0] == "unknown"
+7 -4
View File
@@ -826,6 +826,7 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id
location_query = str(contact.get('location_query') or '').strip()
linkedin_url = str(contact.get('linkedin_url') or '').strip()
phone = str(contact.get('phone') or '').strip()
mobile = str(contact.get('mobile') or '').strip()
if not full_name and not email:
return None
first_name, last_name = _split_full_name(full_name)
@@ -871,20 +872,21 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id
next_location_query = location_query or str(existing['location_query'] or '')
next_linkedin = linkedin_url or str(existing['linkedin_url'] or '')
next_phone = phone or str(existing['phone'] or '')
next_mobile = mobile or str(existing['mobile'] or '')
next_org = org_id or existing['organization_id']
conn.execute("""
UPDATE contacts
SET first_name = ?, last_name = ?, email = ?, title = ?,
organization_id = ?, source = ?, contact_type = 'investor', city = ?, state = ?, country = ?, location_query = ?, linkedin_url = ?, phone = ?, updated_at = ?
organization_id = ?, source = ?, contact_type = 'investor', city = ?, state = ?, country = ?, location_query = ?, linkedin_url = ?, phone = ?, mobile = ?, updated_at = ?
WHERE id = ?
""", (next_first, next_last, next_email, next_title, next_org, next_source, next_city, next_state, next_country, next_location_query, next_linkedin, next_phone, now(), existing['id']))
""", (next_first, next_last, next_email, next_title, next_org, next_source, next_city, next_state, next_country, next_location_query, next_linkedin, next_phone, next_mobile, now(), existing['id']))
return existing['id']
contact_id = generate_id()
conn.execute("""
INSERT INTO contacts (
id, first_name, last_name, email, title, organization_id, source, contact_type, status, city, state, country, location_query, linkedin_url, phone, created_by, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'investor', 'active', ?, ?, ?, ?, ?, ?, ?, ?)
id, first_name, last_name, email, title, organization_id, source, contact_type, status, city, state, country, location_query, linkedin_url, phone, mobile, created_by, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'investor', 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
contact_id,
first_name or 'Unknown',
@@ -899,6 +901,7 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id
location_query,
linkedin_url,
phone,
mobile,
actor_user_id,
now()
))
+11 -10
View File
@@ -165,22 +165,23 @@ def main():
# The Matrix card flow sends these on the contact dict; the upsert must persist them.
print("\n[contact fields: phone + city + linkedin persist on the contact]")
st, d = _req(port, "POST", "/api/fundraising/log-communication", token, {
"investor_name": "MARA Holdings", "create_investor_if_missing": True,
"contact": {"name": "Doug Mellinger", "email": "doug@mara.example",
"phone": "1.914.456.2146", "city": "New York",
"linkedin_url": "linkedin.com/in/dougmellinger"},
"investor_name": "Fortitude Investment Group", "create_investor_if_missing": True,
"contact": {"name": "Daniel Raupp", "email": "draupp@fortitude.example",
"phone": "631-474-5610", "mobile": "631-922-1195", "city": "Setauket, NY",
"linkedin_url": "linkedin.com/in/danielraupp"},
"type": "note", "body": "from a business card", "append_note": True,
})
check(st == 201, f"create with contact fields -> 201 (got {st})")
c = _db()
crow = c.execute("SELECT phone, city, linkedin_url FROM contacts WHERE lower(email) = ?",
("doug@mara.example",)).fetchone()
crow = c.execute("SELECT phone, mobile, city, linkedin_url FROM contacts WHERE lower(email) = ?",
("draupp@fortitude.example",)).fetchone()
c.close()
check(crow is not None, "contact row exists")
check(bool(crow) and crow[0] == "1.914.456.2146", f"phone persisted (got {crow[0] if crow else None!r})")
check(bool(crow) and crow[1] == "New York", f"city persisted (got {crow[1] if crow else None!r})")
check(bool(crow) and crow[2] == "linkedin.com/in/dougmellinger",
f"linkedin persisted (got {crow[2] if crow else None!r})")
check(bool(crow) and crow[0] == "631-474-5610", f"phone (office) persisted (got {crow[0] if crow else None!r})")
check(bool(crow) and crow[1] == "631-922-1195", f"mobile (cell) persisted (got {crow[1] if crow else None!r})")
check(bool(crow) and crow[2] == "Setauket, NY", f"city persisted (got {crow[2] if crow else None!r})")
check(bool(crow) and crow[3] == "linkedin.com/in/danielraupp",
f"linkedin persisted (got {crow[3] if crow else None!r})")
# ── unknown source_row_id is refused (guard) ──
print("\n[guard: reminder on an unknown source_row_id -> 404]")