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)
|
return self.handle_create_reminder(user, body)
|
||||||
if path == '/api/query/nl':
|
if path == '/api/query/nl':
|
||||||
return self.handle_nl_query(user, body)
|
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':
|
if path == '/api/fundraising/collab/heartbeat':
|
||||||
return self.handle_fundraising_collab_heartbeat(user, body)
|
return self.handle_fundraising_collab_heartbeat(user, body)
|
||||||
if path == '/api/admin/users':
|
if path == '/api/admin/users':
|
||||||
@@ -4097,6 +4099,93 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
conn.close()
|
conn.close()
|
||||||
return self.send_json({"data": {"match": match, "candidates": candidates}})
|
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):
|
def handle_update_communication(self, user, comm_id, body):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
existing = conn.execute("SELECT id FROM communications WHERE id = ?", (comm_id,)).fetchone()
|
existing = conn.execute("SELECT id FROM communications WHERE id = ?", (comm_id,)).fetchone()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -2596,6 +2596,23 @@
|
|||||||
.quicklog-change { flex: none; background: none; border: none; color: var(--accent-light); font-size: 13px; cursor: pointer; font-family: inherit; }
|
.quicklog-change { flex: none; background: none; border: none; color: var(--accent-light); font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||||
.quicklog-warn { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin-bottom: 14px; }
|
.quicklog-warn { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin-bottom: 14px; }
|
||||||
|
|
||||||
|
/* In-app business-card capture (#7) — top-bar camera button + transcribe/review sheet.
|
||||||
|
Reuses .quicklog-btn (incl. the iOS svg sizing fix) + the shared .sheet-* form fields. */
|
||||||
|
.card-reading { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 30px 4px 24px; }
|
||||||
|
.card-reading-text { font-size: 14px; color: var(--text-secondary); }
|
||||||
|
.card-error { font-size: 14px; color: var(--text-secondary); line-height: 1.55; padding: 6px 2px 16px; }
|
||||||
|
.card-inv-pick { display: flex; flex-direction: column; gap: 6px; margin: -4px 0 14px; }
|
||||||
|
.card-inv-opt {
|
||||||
|
width: 100%; text-align: left; cursor: pointer; font-family: inherit;
|
||||||
|
background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius);
|
||||||
|
padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||||
|
color: var(--text-primary); font-size: 14px;
|
||||||
|
}
|
||||||
|
.card-inv-opt.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-light); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.card-inv-opt-sub { flex: none; font-family: 'IBM Plex Mono', monospace; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent); }
|
||||||
|
.card-field-grid { display: flex; gap: 10px; }
|
||||||
|
.card-field-grid > .sheet-field { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
/* Full-screen detail: read-only sections + edit-entry buttons. */
|
/* Full-screen detail: read-only sections + edit-entry buttons. */
|
||||||
.fs-detail-chips { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
.fs-detail-chips { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||||
.fs-action-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
.fs-action-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
@@ -14230,6 +14247,211 @@
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// In-app business-card intake (#7): a top-bar camera button → take/pick a photo →
|
||||||
|
// vision-transcribe + parse on the box (POST /api/intake/card; local VL via Spark Control,
|
||||||
|
// nothing to Claude) → an editable review sheet the human approves. Nothing is written until
|
||||||
|
// Save; the approve write reuses log-communication tagged source='app_card'. Mirrors the
|
||||||
|
// Matrix card flow (M3) but in-app. See docs/handoffs/in-app-card-intake-plan.md.
|
||||||
|
const MAX_CARD_DIM = 2000; // downscale longest edge before upload — keeps the payload under the
|
||||||
|
// StartOS reverse-proxy body cap; the VL model downscales to ~2 MP anyway.
|
||||||
|
|
||||||
|
// File → off-DOM <canvas> scaled to MAX_CARD_DIM → JPEG base64 (no data: prefix). Native, no
|
||||||
|
// library. Re-encoding to JPEG also sidesteps iPhone HEIC-in-vLLM (Safari decodes HEIC to the canvas).
|
||||||
|
const cardImageToB64 = (file) => new Promise((resolve, reject) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
const longest = Math.max(img.width, img.height) || 1;
|
||||||
|
const scale = Math.min(1, MAX_CARD_DIM / longest);
|
||||||
|
const w = Math.max(1, Math.round(img.width * scale));
|
||||||
|
const h = Math.max(1, Math.round(img.height * scale));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w; canvas.height = h;
|
||||||
|
try {
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
||||||
|
resolve((canvas.toDataURL('image/jpeg', 0.85).split(',')[1]) || '');
|
||||||
|
} catch (e) { reject(e); }
|
||||||
|
};
|
||||||
|
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('unreadable image')); };
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
|
||||||
|
const MobileCardCapture = ({ token, onShowToast }) => {
|
||||||
|
const fileRef = useRef(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [stage, setStage] = useState('reading'); // 'reading' | 'error' | 'review'
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
const [form, setForm] = useState(null); // editable proposal fields
|
||||||
|
const [match, setMatch] = useState(null); // exact existing investor, or null
|
||||||
|
const [candidates, setCandidates] = useState([]); // fuzzy near-matches when no exact match
|
||||||
|
const [attachId, setAttachId] = useState(null); // grid row id to attach to; null = add as new
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Reset value first so re-picking the SAME file still fires onChange.
|
||||||
|
const pickFile = () => { const el = fileRef.current; if (el) { el.value = ''; el.click(); } };
|
||||||
|
|
||||||
|
const onFile = async (e) => {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
setOpen(true); setStage('reading'); setErrorMsg(''); setForm(null);
|
||||||
|
setMatch(null); setCandidates([]); setAttachId(null); setBusy(false);
|
||||||
|
let image_b64;
|
||||||
|
try {
|
||||||
|
image_b64 = await cardImageToB64(file);
|
||||||
|
} catch (_) {
|
||||||
|
setStage('error'); setErrorMsg("Couldn't read that image — try another photo."); return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await api('/api/intake/card', { method: 'POST',
|
||||||
|
body: JSON.stringify({ image_b64, mime: 'image/jpeg' }) }, token);
|
||||||
|
const d = (resp && resp.data) || {};
|
||||||
|
if (!d.ok) { // 200 soft-fail: the model saw no readable card
|
||||||
|
setStage('error');
|
||||||
|
setErrorMsg('Could not read the card. Use a clearer, well-lit photo that fills the frame.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = d.proposal || {};
|
||||||
|
setForm({
|
||||||
|
investorName: p.investor_name || '', contactName: p.contact_name || '',
|
||||||
|
contactEmail: p.contact_email || '', contactTitle: p.contact_title || '',
|
||||||
|
city: p.city || '', phone: p.phone || '', mobile: p.mobile || '',
|
||||||
|
linkedinUrl: p.linkedin_url || '', note: p.note || '',
|
||||||
|
});
|
||||||
|
setMatch(d.match || null);
|
||||||
|
setCandidates(Array.isArray(d.candidates) ? d.candidates : []);
|
||||||
|
setAttachId(d.match ? d.match.id : null); // default: attach to an exact match
|
||||||
|
setStage('review');
|
||||||
|
} catch (err) {
|
||||||
|
setStage('error');
|
||||||
|
const reason = err && err.payload && err.payload.data && err.payload.data.reason;
|
||||||
|
setErrorMsg(reason === 'vision_unavailable'
|
||||||
|
? 'The card reader is unavailable right now — try again in a moment.'
|
||||||
|
: getErrorMessage(err, 'Failed to read the card'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setOpen(false);
|
||||||
|
const setField = (k, v) => setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
const investorOptions = match ? [match] : candidates;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!form) return;
|
||||||
|
const name = (form.investorName || '').trim();
|
||||||
|
const cName = (form.contactName || '').trim();
|
||||||
|
const cEmail = (form.contactEmail || '').trim();
|
||||||
|
if (!attachId && !name) { onShowToast('Investor name is required', 'error'); return; }
|
||||||
|
if (!cName && !cEmail) { onShowToast('Add a contact name or email', 'error'); return; }
|
||||||
|
setBusy(true);
|
||||||
|
const note = (form.note || '').trim();
|
||||||
|
// Mirror the bot's build_commit_payload: blank subject when there's a note (so the note
|
||||||
|
// shows in the grid line), a provenance label otherwise; contact carries the extra fields.
|
||||||
|
const body = {
|
||||||
|
contact: {
|
||||||
|
name: cName, email: cEmail, title: (form.contactTitle || '').trim(),
|
||||||
|
city: (form.city || '').trim(), linkedin_url: (form.linkedinUrl || '').trim(),
|
||||||
|
phone: (form.phone || '').trim(), mobile: (form.mobile || '').trim(),
|
||||||
|
},
|
||||||
|
type: 'note', body: note, subject: note ? '' : 'Business card',
|
||||||
|
append_note: true, source: 'app_card',
|
||||||
|
};
|
||||||
|
if (attachId) body.row_id = attachId;
|
||||||
|
else { body.investor_name = name; body.create_investor_if_missing = true; }
|
||||||
|
try {
|
||||||
|
await api('/api/fundraising/log-communication', { method: 'POST', body: JSON.stringify(body) }, token);
|
||||||
|
onShowToast(attachId ? 'Logged to existing investor' : `Added ${name}`, 'success');
|
||||||
|
close();
|
||||||
|
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to save'), 'error'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className="quicklog-btn" onClick={pickFile} aria-label="Scan business card" title="Scan business card">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||||
|
<circle cx="12" cy="13" r="4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* Omit `capture` so iOS offers Take Photo / Photo Library / Browse (take a photo OR pick one). */}
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} aria-hidden="true" tabIndex={-1} onChange={onFile} />
|
||||||
|
<BottomSheet open={open} onClose={close} title="Scan business card">
|
||||||
|
{stage === 'reading' && (
|
||||||
|
<div className="card-reading">
|
||||||
|
<Spinner />
|
||||||
|
<div className="card-reading-text">📇 Reading the card…</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stage === 'error' && (
|
||||||
|
<>
|
||||||
|
<div className="card-error">{errorMsg}</div>
|
||||||
|
<button className="sheet-submit" onClick={pickFile}>Retake</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{stage === 'review' && form && (
|
||||||
|
<>
|
||||||
|
{investorOptions.length > 0 && (
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">{match ? 'Existing investor found' : 'Possible existing matches'}</label>
|
||||||
|
<div className="card-inv-pick">
|
||||||
|
{investorOptions.map((o) => (
|
||||||
|
<button type="button" key={o.id} className={`card-inv-opt ${attachId === o.id ? 'active' : ''}`} onClick={() => setAttachId(o.id)}>
|
||||||
|
<span>{o.investor_name || o.name || 'Investor'}</span>
|
||||||
|
{attachId === o.id && <span className="card-inv-opt-sub">attach</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className={`card-inv-opt ${attachId == null ? 'active' : ''}`} onClick={() => setAttachId(null)}>
|
||||||
|
<span>Add as new investor</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Investor / firm</label>
|
||||||
|
<input className="sheet-input" value={form.investorName} onChange={(e) => setField('investorName', e.target.value)} placeholder="e.g. Acme Capital" disabled={!!attachId} />
|
||||||
|
</div>
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Contact name</label>
|
||||||
|
<input className="sheet-input" value={form.contactName} onChange={(e) => setField('contactName', e.target.value)} placeholder="Full name" />
|
||||||
|
</div>
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Email</label>
|
||||||
|
<input className="sheet-input" type="email" value={form.contactEmail} onChange={(e) => setField('contactEmail', e.target.value)} placeholder="name@firm.com" />
|
||||||
|
</div>
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Title</label>
|
||||||
|
<input className="sheet-input" value={form.contactTitle} onChange={(e) => setField('contactTitle', e.target.value)} placeholder="e.g. Managing Partner" />
|
||||||
|
</div>
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">City</label>
|
||||||
|
<input className="sheet-input" value={form.city} onChange={(e) => setField('city', e.target.value)} placeholder="e.g. New York" />
|
||||||
|
</div>
|
||||||
|
<div className="card-field-grid">
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Phone</label>
|
||||||
|
<input className="sheet-input" value={form.phone} onChange={(e) => setField('phone', e.target.value)} placeholder="Office" />
|
||||||
|
</div>
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Mobile</label>
|
||||||
|
<input className="sheet-input" value={form.mobile} onChange={(e) => setField('mobile', e.target.value)} placeholder="Cell" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">LinkedIn</label>
|
||||||
|
<input className="sheet-input" value={form.linkedinUrl} onChange={(e) => setField('linkedinUrl', e.target.value)} placeholder="linkedin.com/in/…" />
|
||||||
|
</div>
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Note (optional)</label>
|
||||||
|
<textarea className="sheet-textarea" value={form.note} onChange={(e) => setField('note', e.target.value)} placeholder="How you met, context…" />
|
||||||
|
</div>
|
||||||
|
<button className="sheet-submit" onClick={save} disabled={busy}>{busy ? 'Saving…' : (attachId ? 'Log to investor' : 'Add investor')}</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</BottomSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Quick-log — the dc top-bar pencil (GridApp:53-55): log a communication against any investor
|
// Quick-log — the dc top-bar pencil (GridApp:53-55): log a communication against any investor
|
||||||
// without first opening its detail. Two steps: pick an investor (search + recent-first pool) →
|
// without first opening its detail. Two steps: pick an investor (search + recent-first pool) →
|
||||||
// inline log form. Writes via the one-row /api/fundraising/log-communication path (same write
|
// inline log form. Writes via the one-row /api/fundraising/log-communication path (same write
|
||||||
@@ -14648,6 +14870,7 @@
|
|||||||
{user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''}
|
{user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div className="mobile-only" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div className="mobile-only" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<MobileCardCapture token={token} onShowToast={showToast} />
|
||||||
<MobileQuickLog token={token} onShowToast={showToast} />
|
<MobileQuickLog token={token} onShowToast={showToast} />
|
||||||
<ThemeToggle theme={theme} onToggle={toggleTheme} variant="icon" />
|
<ThemeToggle theme={theme} onToggle={toggleTheme} variant="icon" />
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
|||||||
// * 0.1.0:96 (login page mobile/PWA conformance — the one surface the v95 mobile redesign skipped. CSS-only: 100vh→100dvh [dynamic viewport, fixes the centered card tucking under the iOS standalone status bar], a <768px media query adding 16px screen gutters + env[safe-area-inset] top/bottom clearance + touch-sized fields [inputs 46px/15px, button 46px], full-bleed card on small phones, and the §4 card depth shadow on the login card to match .section. No markup/JS/schema change; desktop login unchanged)
|
// * 0.1.0:96 (login page mobile/PWA conformance — the one surface the v95 mobile redesign skipped. CSS-only: 100vh→100dvh [dynamic viewport, fixes the centered card tucking under the iOS standalone status bar], a <768px media query adding 16px screen gutters + env[safe-area-inset] top/bottom clearance + touch-sized fields [inputs 46px/15px, button 46px], full-bleed card on small phones, and the §4 card depth shadow on the login card to match .section. No markup/JS/schema change; desktop login unchanged)
|
||||||
// * 0.1.0:97 (mobile top-bar polish + native zoom behaviour. Viewport meta gains maximum-scale=1 + user-scalable=no: kills pinch-zoom AND the iOS auto-zoom-on-focus that jerked the page in on every <16px input tap [app-wide, not just login]; OS accessibility zoom still works. Top-bar account initial now flex-centered + dc-aligned [IBM Plex Mono, accent-light, 13px — was defaulting to inline/baseline, off-center]. Quick-log pencil bumped --text-muted→--text-secondary for real affordance [the dc t3 grey thin-outline read as empty next to the color sun emoji on-device]. CSS-only; no JS/schema change)
|
// * 0.1.0:97 (mobile top-bar polish + native zoom behaviour. Viewport meta gains maximum-scale=1 + user-scalable=no: kills pinch-zoom AND the iOS auto-zoom-on-focus that jerked the page in on every <16px input tap [app-wide, not just login]; OS accessibility zoom still works. Top-bar account initial now flex-centered + dc-aligned [IBM Plex Mono, accent-light, 13px — was defaulting to inline/baseline, off-center]. Quick-log pencil bumped --text-muted→--text-secondary for real affordance [the dc t3 grey thin-outline read as empty next to the color sun emoji on-device]. CSS-only; no JS/schema change)
|
||||||
// * 0.1.0:98 (business-card intake [Matrix bot] captures a contact's phone, mobile/cell, city + LinkedIn from a scanned card onto the contact record — cell to Mobile, office to Phone, fax skipped. Server half: _upsert_contact_from_fundraising now accepts phone+mobile on the contact dict [city+linkedin already worked]. The bot's transcription/extraction/card changes ship on the Spark [git pull + rebuild]. No schema change [contacts columns already exist]; no user-facing CRM change)
|
// * 0.1.0:98 (business-card intake [Matrix bot] captures a contact's phone, mobile/cell, city + LinkedIn from a scanned card onto the contact record — cell to Mobile, office to Phone, fax skipped. Server half: _upsert_contact_from_fundraising now accepts phone+mobile on the contact dict [city+linkedin already worked]. The bot's transcription/extraction/card changes ship on the Spark [git pull + rebuild]. No schema change [contacts columns already exist]; no user-facing CRM change)
|
||||||
// * Current: 0.1.0:99 (Grant device-test round 2, CRM half: intake fuzzy match scores DISTINCTIVE tokens only [no more "Investment Group"/"Capital"/"Family Office" false look-alikes]; mobile grid "Last contact"/staleness sort is reversible; mobile Edit-investor prefills a contact's email [GET /api/fundraising/state heals a blank grid pill from the linked classic contact, fill-only]; mobile quick-log pencil icon renders [CSS sizing on the sole flex-child svg]. The Matrix intake thread-redaction change ships on the Spark, not here. No schema change; no migration)
|
// * 0.1.0:99 (Grant device-test round 2, CRM half: intake fuzzy match scores DISTINCTIVE tokens only [no more "Investment Group"/"Capital"/"Family Office" false look-alikes]; mobile grid "Last contact"/staleness sort is reversible; mobile Edit-investor prefills a contact's email [GET /api/fundraising/state heals a blank grid pill from the linked classic contact, fill-only]; mobile quick-log pencil icon renders [CSS sizing on the sole flex-child svg]. The Matrix intake thread-redaction change ships on the Spark, not here. No schema change; no migration)
|
||||||
export const PACKAGE_VERSION = '0.1.0:99'
|
// * Current: 0.1.0:100 (In-app business-card intake [#7]: a mobile camera button [left of the quick-log pencil] takes/picks a card photo, downscales it client-side via <canvas> to JPEG, and POSTs to the new POST /api/intake/card — vision-transcribe + parse + fuzzy-match on the box [local VL via Spark Control, nothing to Claude], reusing the Matrix card flow's nio-free parse/spark core. An editable review sheet [proposal fields + existing-investor picker] writes via log-communication tagged source="app_card"; a human approves every write. No schema change; no migration; no new dependency)
|
||||||
|
export const PACKAGE_VERSION = '0.1.0:100'
|
||||||
|
|
||||||
export const DATA_MOUNT_PATH = '/data'
|
export const DATA_MOUNT_PATH = '/data'
|
||||||
export const WEB_PORT = 8080
|
export const WEB_PORT = 8080
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ import { v_0_1_0_96 } from './v0.1.0.96'
|
|||||||
import { v_0_1_0_97 } from './v0.1.0.97'
|
import { v_0_1_0_97 } from './v0.1.0.97'
|
||||||
import { v_0_1_0_98 } from './v0.1.0.98'
|
import { v_0_1_0_98 } from './v0.1.0.98'
|
||||||
import { v_0_1_0_99 } from './v0.1.0.99'
|
import { v_0_1_0_99 } from './v0.1.0.99'
|
||||||
|
import { v_0_1_0_100 } from './v0.1.0.100'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_1_0_99,
|
current: v_0_1_0_100,
|
||||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98],
|
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98, v_0_1_0_99],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
// In-app business-card intake (#7) — a mobile, in-app twin of the Matrix card flow (M3):
|
||||||
|
// - 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 + match/candidates. Nothing is written here.
|
||||||
|
// - Frontend: a camera button in the mobile top bar (left of the quick-log pencil) → take/pick a
|
||||||
|
// photo → <canvas> downscale to JPEG (also normalizes iPhone HEIC) → the endpoint → an editable
|
||||||
|
// review sheet. Save reuses log-communication tagged source="app_card"; a human approves.
|
||||||
|
// No schema change — no migration. No new dependency.
|
||||||
|
export const v_0_1_0_100 = VersionInfo.of({
|
||||||
|
version: '0.1.0:100',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US: [
|
||||||
|
'In-app business-card scanning: tap the camera in the mobile top bar to photograph a card,',
|
||||||
|
'review the auto-filled name / email / title / phone / city, attach it to an existing investor',
|
||||||
|
'or add a new one, and save — the same on-box card reader the Matrix bot uses, now in the app.',
|
||||||
|
].join(' '),
|
||||||
|
},
|
||||||
|
migrations: { up: async () => {}, down: async () => {} },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user