Add in-app camera business-card intake (#7) (v0.1.0:100)

A mobile, in-app twin of the Matrix business-card flow (M3): photograph a
card in the app and it becomes a reviewed fundraising-grid add/note, with a
human approving every write.

Server — POST /api/intake/card (authenticated member+, read-only): lazily
imports the bot's nio-free parse + spark core, vision-transcribes the photo
(local VL via Spark Control — nothing to Claude), runs the same email/phone/
LinkedIn integrity rule + fuzzy matcher, and returns a proposal plus exact
match / fuzzy candidates. No write happens here.

Frontend — a camera button in the mobile top bar (left of the quick-log
pencil) → take or pick a photo → <canvas> downscale to JPEG (also normalizes
iPhone HEIC) → the endpoint → an editable review sheet (proposal fields +
existing-investor picker). Save reuses /api/fundraising/log-communication
tagged source="app_card".

No schema change, no migration, no new dependency, no Matrix-bot change. The
camera/canvas/OCR path is on-device-only (jsdom has no canvas); covered by
test_intake_card.py (stubbed vision+parse) + the render/mount smokes.
This commit is contained in:
Keysat
2026-06-20 14:15:03 -05:00
parent 2a4c2c25a0
commit 463f624548
6 changed files with 615 additions and 4 deletions
+89
View File
@@ -2517,6 +2517,8 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.handle_create_reminder(user, body)
if path == '/api/query/nl':
return self.handle_nl_query(user, body)
if path == '/api/intake/card':
return self.handle_intake_card(user, body)
if path == '/api/fundraising/collab/heartbeat':
return self.handle_fundraising_collab_heartbeat(user, body)
if path == '/api/admin/users':
@@ -4097,6 +4099,93 @@ class CRMHandler(BaseHTTPRequestHandler):
conn.close()
return self.send_json({"data": {"match": match, "candidates": candidates}})
def handle_intake_card(self, user, body):
"""In-app business-card intake (#7): a mobile photo -> vision-transcribe (local VL via
Spark Control) -> the SAME text-parse + fuzzy match the Matrix card flow runs (M3),
minus Matrix, surfaced as a mobile sheet. READ-ONLY: nothing is written here. The
proposal it returns is edited + approved by a human, and that approve write reuses
POST /api/fundraising/log-communication tagged source='app_card'. Authenticated member+
(a human approves every write same draft->approve gate as the bot, not bot-/admin-only).
Body: {image_b64 (raw base64; a data: URI is also tolerated), mime?}. On success returns
status 200 {ok:true, transcription, proposal, match, candidates}; soft-fails also return
200 (or 502) with {ok:false, reason}: 'unreadable' (model saw no card / <5 chars),
'vision_unavailable' (502 Spark/model down, or the intake core failed to import on a
dev box without it). See docs/handoffs/in-app-card-intake-plan.md + the matrix-intake guide."""
image_b64 = str(body.get('image_b64') or '').strip()
mime = (str(body.get('mime') or 'image/jpeg').strip() or 'image/jpeg')[:64]
# Tolerate a full data-URI (clients often send the whole `data:...;base64,XXXX`):
# llm.chat_vision builds the `data:` wrapper itself, so strip any prefix to raw base64.
if image_b64.startswith('data:'):
comma = image_b64.find(',')
if comma != -1:
image_b64 = image_b64[comma + 1:].strip()
if not image_b64:
return self.send_error_json("image_b64 is required")
# Size guard: the client downscales to ~2 MP before base64, so a legit card is well under
# this; reject larger up front so a huge upload can't tie up a worker thread. (base64
# inflates ~4/3; 12 MB b64 ~= 9 MB image — generous for a downscaled card.)
if len(image_b64) > 12_000_000:
return self.send_error_json("Image too large; retake a smaller photo", 413)
try:
base64.b64decode(image_b64, validate=True)
except Exception:
return self.send_error_json("image_b64 is not valid base64")
# Lazily import the nio-free intake core (parse + spark) reused from the Matrix bot,
# guarded like _summarize_email_gist's `import llm`: a dev box without the modules or with
# Spark unreachable still boots and this endpoint just 502s. Import ONLY parse + spark
# (both nio-free) — never the bot's crm_client/settings/bot. (matrix-intake guide: the
# bot imports by bare name; parse/spark don't collide with ingest's modules.)
try:
_intake_dir = os.path.join(BASE_DIR, "matrix_intake")
if _intake_dir not in sys.path:
sys.path.insert(0, _intake_dir)
import parse as _intake_parse # noqa: E402
import spark as _intake_spark # noqa: E402
except Exception:
return self.send_json({"data": {"ok": False, "reason": "vision_unavailable"}}, 502)
# The one added step vs the text path: vision-transcribe. The model only TRANSCRIBES; the
# text parser then extracts — so the email/phone/LinkedIn integrity rule (a value is kept
# only if it literally appears in the transcription, never minted) protects card intake too.
try:
transcription = _intake_spark.transcribe_card(image_b64, mime)
except Exception:
return self.send_json({"data": {"ok": False, "reason": "vision_unavailable"}}, 502)
if len((transcription or "").strip()) < 5:
return self.send_json({"data": {"ok": False, "reason": "unreadable"}})
# Frame as a new-investor note so the extractor reads it that way (same framing as M3).
framed = "New investor — from a business card:\n" + transcription
try:
proposal = _intake_parse.parse_message(framed, roster=None)
except Exception:
return self.send_json({"data": {"ok": False, "reason": "vision_unavailable"}}, 502)
# Same new-vs-existing resolution the bot uses (crm_client.match): q = firm-or-person.
q = str(proposal.get("investor_name") or proposal.get("contact_name") or "").strip()
email = str(proposal.get("contact_email") or "").strip()
match = None
candidates = []
if q or email:
conn = get_db()
try:
match = find_intake_match(conn, q, email)
candidates = find_intake_candidates(conn, q, email) if match is None else []
finally:
conn.close()
# Drop internal control keys (e.g. _source_text) before handing the proposal to the client.
proposal_out = {k: v for k, v in proposal.items() if not k.startswith("_")}
return self.send_json({"data": {
"ok": True,
"transcription": transcription,
"proposal": proposal_out,
"match": match,
"candidates": candidates,
}})
def handle_update_communication(self, user, comm_id, body):
conn = get_db()
existing = conn.execute("SELECT id FROM communications WHERE id = ?", (comm_id,)).fetchone()
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Tests for the in-app business-card intake endpoint (#7): POST /api/intake/card.
The endpoint reuses the Matrix card flow's nio-free core — vision-transcribe (spark) -> text
parse (parse) -> the same fuzzy matcher (find_intake_match / find_intake_candidates) — minus
Matrix, surfaced for a mobile sheet. The real vision/OCR path is live-smoke only (same as the
Matrix M3 path), so here we STUB the two network legs and assert the wiring + contract:
- happy path: transcribe -> parse -> proposal + match/candidates, status 200 ok:true;
- the email-integrity rule rides along (a model-minted address NOT in the transcription is
dropped in favor of the one literally present), exactly as on the text/Matrix path;
- new-vs-existing: an exact firm name returns `match`; a near-spelling returns `candidates`;
- soft-fails: an unreadable image -> ok:false/unreadable; vision down -> 502/vision_unavailable;
- guards: missing/invalid image -> 400; unauthenticated -> 401;
- provenance: the approve write reuses log-communication tagged source="app_card".
Synthetic data only.
Run: cd backend && python3 test_intake_card.py
"""
import base64
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
from http.server import ThreadingHTTPServer
_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
_BACKEND = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _BACKEND)
sys.path.insert(0, os.path.join(_BACKEND, "ingest")) # llm
sys.path.insert(0, os.path.join(_BACKEND, "matrix_intake")) # spark, parse
import server # noqa: E402
import llm # noqa: E402 (ingest/llm.py — patched so spark.parse_json hits no network)
import spark # noqa: E402 (matrix_intake/spark.py — transcribe_card stubbed)
import parse # noqa: E402 (matrix_intake/parse.py — parse_message defaults to spark.parse_json)
FAILS = []
# The handler imports `spark`/`parse` lazily and looks up transcribe_card on the module at call
# time, so patching the module attribute here takes effect. parse.parse_message binds its default
# parse_fn=spark.parse_json at import, and spark.parse_json calls llm.chat_json dynamically — so
# patching llm.chat_json (not spark.parse_json) is what reaches the parse leg.
_STATE = {"transcription": "", "raw": {}, "boom": False}
def _fake_transcribe(image_b64, mime="image/jpeg", chat_fn=None):
if _STATE["boom"]:
raise RuntimeError("spark control unreachable")
return _STATE["transcription"]
def _fake_chat_json(prompt, system=None, max_tokens=200):
return dict(_STATE["raw"])
spark.transcribe_card = _fake_transcribe
llm.chat_json = _fake_chat_json
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
class _Quiet(server.CRMHandler):
def log_message(self, *a):
pass
def _req(port, method, path, token=None, body=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
headers = {}
if token:
headers["Authorization"] = "Bearer " + token
payload = None
if body is not None:
payload = json.dumps(body)
headers["Content-Type"] = "application/json"
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode("utf-8", "replace")
conn.close()
data = None
if raw:
try:
data = json.loads(raw)
except ValueError:
pass
return resp.status, data
GRID = {
"columns": [],
"rows": [
{"id": "rowAcme", "investor_name": "Acme Capital", "notes": "",
"contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]},
],
}
_IMG = base64.b64encode(b"not-a-real-image-just-valid-base64").decode()
def seed():
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
c.execute("INSERT INTO fundraising_state (id, grid_json, views_json, version) "
"VALUES ('main', ?, '[]', 1) "
"ON CONFLICT(id) DO UPDATE SET grid_json = excluded.grid_json", (json.dumps(GRID),))
c.commit()
c.close()
def main():
server.init_db()
seed()
token = server.create_token("u1", "grant", "admin")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
print("\n[happy path: transcribe -> parse -> proposal, new investor, no match]")
_STATE["transcription"] = ("Sam Lee\nPartner\nBeacon Ventures\n"
"sam@beacon.vc\nMobile: +1 555 987 6543")
_STATE["raw"] = {"intent": "new_investor", "investor_name": "Beacon Ventures",
"contact_name": "Sam Lee", "contact_title": "Partner",
"mobile": "+1 555 987 6543", "contact_email": "sam@beacon.vc"}
_STATE["boom"] = False
st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG})
data = (d or {}).get("data", {})
p = data.get("proposal", {})
check(st == 200 and data.get("ok") is True, f"200 ok:true (got {st}, {data})")
check(p.get("investor_name") == "Beacon Ventures" and p.get("contact_name") == "Sam Lee",
f"proposal carries firm + person (got {p})")
check(p.get("contact_email") == "sam@beacon.vc", f"email kept (got {p.get('contact_email')})")
check(p.get("mobile") == "+1 555 987 6543", f"mobile kept (got {p.get('mobile')})")
check("transcription" in data and data["match"] is None and data["candidates"] == [],
f"transcription returned, unknown firm -> no match/candidates (got {data})")
check(not any(k.startswith("_") for k in p), f"internal control keys stripped (got {list(p)})")
print("\n[email integrity: a model-minted address NOT in the card is dropped]")
_STATE["transcription"] = "Ann Roe\nDir\nOmega LP\nann@omega.fund" # the only address present
_STATE["raw"] = {"intent": "new_investor", "investor_name": "Omega LP",
"contact_name": "Ann Roe", "contact_email": "evil@phish.example"}
st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG})
p = (d or {}).get("data", {}).get("proposal", {})
check(p.get("contact_email") == "ann@omega.fund",
f"source address wins over the minted one (got {p.get('contact_email')})")
print("\n[match: exact firm name returns the grid row id]")
_STATE["transcription"] = "Jane Doe\nGP\nAcme Capital" # no email -> match on name
_STATE["raw"] = {"intent": "new_investor", "investor_name": "Acme Capital",
"contact_name": "Jane Doe", "contact_title": "GP"}
st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG})
m = (d or {}).get("data", {}).get("match")
check(m and m.get("id") == "rowAcme", f"exact firm -> match rowAcme (got {m})")
print("\n[match by card email: exact contact email returns the grid row id]")
_STATE["transcription"] = "Jane Doe\nGP\nAcme Capital Group\njane@acme.com"
_STATE["raw"] = {"intent": "new_investor", "investor_name": "Acme Capital Group",
"contact_name": "Jane Doe"}
st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG})
m = (d or {}).get("data", {}).get("match")
check(m and m.get("id") == "rowAcme" and m.get("matched_on") == "email",
f"card email -> exact match rowAcme on email (got {m})")
print("\n[fuzzy: a near-spelling returns a candidate, no exact match]")
# Typo in the DISTINCTIVE token ('Acme'->'Acne') so the fuzzy matcher surfaces it; a typo
# in a generic descriptor (e.g. 'Capitol') wouldn't, since those are stripped first.
_STATE["transcription"] = "Jane Doe\nGP\nAcne Capital" # no email -> name-only fuzzy
_STATE["raw"] = {"intent": "new_investor", "investor_name": "Acne Capital",
"contact_name": "Jane Doe"}
st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG})
data = (d or {}).get("data", {})
cids = [c["id"] for c in data.get("candidates", [])]
check(data.get("match") is None and "rowAcme" in cids,
f"near-spelling -> candidate rowAcme, no exact (got {data})")
print("\n[no firm and no person: readable but unactionable -> ok:true, no DB lookup, no 500]")
_STATE["transcription"] = "some faded scribbles, no usable fields" # >=5 chars, no email/firm
_STATE["raw"] = {"intent": "unclear"}
st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG})
data = (d or {}).get("data", {})
check(st == 200 and data.get("ok") is True
and data.get("match") is None and data.get("candidates") == [],
f"unclear proposal -> ok:true, no match/candidates, not 500 (got {st}, {data})")
print("\n[parse leg down: parse_message raises -> 502/vision_unavailable]")
_orig_pm = parse.parse_message
parse.parse_message = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("qwen down"))
try:
_STATE["transcription"] = "Jane Doe\nGP\nAcme Capital"
st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG})
data = (d or {}).get("data", {})
check(st == 502 and data.get("reason") == "vision_unavailable",
f"parse error -> 502 vision_unavailable (got {st}, {data})")
finally:
parse.parse_message = _orig_pm
print("\n[unreadable: model saw no card -> ok:false/unreadable, 200]")
_STATE["transcription"] = "" # transcribe_card returns '' on the NONE sentinel
st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG})
data = (d or {}).get("data", {})
check(st == 200 and data.get("ok") is False and data.get("reason") == "unreadable",
f"empty transcription -> unreadable (got {st}, {data})")
print("\n[vision down: transcribe raises -> 502/vision_unavailable]")
_STATE["boom"] = True
st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG})
data = (d or {}).get("data", {})
check(st == 502 and data.get("reason") == "vision_unavailable",
f"spark error -> 502 vision_unavailable (got {st}, {data})")
_STATE["boom"] = False
print("\n[data-URI tolerated: a full data: prefix is stripped to raw base64]")
_STATE["transcription"] = "Sam Lee\nPartner\nBeacon Ventures"
_STATE["raw"] = {"intent": "new_investor", "investor_name": "Beacon Ventures",
"contact_name": "Sam Lee"}
st, d = _req(port, "POST", "/api/intake/card", token,
{"image_b64": "data:image/jpeg;base64," + _IMG})
check(st == 200 and (d or {}).get("data", {}).get("ok") is True,
f"data-URI accepted (got {st})")
print("\n[guard: missing image -> 400]")
st, _ = _req(port, "POST", "/api/intake/card", token, {})
check(st == 400, f"no image_b64 -> 400 (got {st})")
print("\n[guard: malformed base64 -> 400]")
st, _ = _req(port, "POST", "/api/intake/card", token, {"image_b64": "%%%not base64%%%"})
check(st == 400, f"invalid base64 -> 400 (got {st})")
print("\n[guard: oversized image -> 413 (size check runs before decode)]")
st, _ = _req(port, "POST", "/api/intake/card", token, {"image_b64": "A" * 12_000_001})
check(st == 413, f"over the 12 MB b64 cap -> 413 (got {st})")
print("\n[guard: unauthenticated -> 401]")
st, _ = _req(port, "POST", "/api/intake/card", None, {"image_b64": _IMG})
check(st == 401, f"no token -> 401 (got {st})")
print("\n[provenance: the approve write reuses log-communication tagged source=app_card]")
st, d = _req(port, "POST", "/api/fundraising/log-communication", token, {
"investor_name": "Beacon Ventures",
"contact": {"name": "Sam Lee", "email": "sam@beacon.vc", "title": "Partner"},
"create_investor_if_missing": True,
"type": "note", "subject": "", "body": "scanned business card",
"source": "app_card",
})
check(st in (200, 201), f"app_card create -> 201 (got {st})")
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
rows = c.execute("SELECT changes FROM audit_log WHERE entity_type='communication' AND action='create'").fetchall()
c.close()
sources = [json.loads(r[0]).get("source") for r in rows if r[0]]
check("app_card" in sources, f"audit carries source=app_card (got {sources})")
finally:
httpd.shutdown()
print()
if FAILS:
print(f"FAILED ({len(FAILS)}):")
for f in FAILS:
print(f" - {f}")
sys.exit(1)
print("ALL PASS (in-app card intake endpoint)")
if __name__ == "__main__":
main()