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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user