Files
ten31-database/docs/handoffs/in-app-card-intake-plan.md
T
Keysat a917280bbb Device-test round 2: 4 in-app fixes + Matrix intake cleanup (v0.1.0:99)
Grant's real-phone testing surfaced seven items; this lands six (the seventh,
in-app camera card intake, is planned in docs/handoffs/in-app-card-intake-plan.md).

CRM half — ships in the s9pk (v0.1.0:99):
- Intake fuzzy match no longer over-indexes on generic firm words. _name_similarity
  now compares DISTINCTIVE tokens only (generic descriptors — "Investment Group",
  "Capital", "Family Office" — stripped via _GENERIC_ORG_WORDS) for both the difflib
  ratio and the Jaccard, so "Fortitude Investment Group" stops surfacing Aether/Russell
  while "Aether Capital" still surfaces "Aether Investment Group". +2 regression cases.
- Mobile grid "Last contact"/staleness sort is reversible. SortSheet gains opt-in
  dir/onToggleDir; other surfaces (Contacts/Pipeline) are untouched.
- Mobile "Edit investor" prefills a contact's saved email. GET /api/fundraising/state
  heals a blank grid pill email from the linked classic contact
  (fundraising_contacts.contact_id -> contacts.email), fill-only, by pill order then
  name; the next one-row save persists it. +test_grid_email_heal.py.
- Mobile quick-log pencil icon renders. iOS collapses a sole, centered, attribute-only
  -sized flex-child <svg>; .quicklog-btn svg now gets explicit CSS width/height + flex:none
  (the pattern the working bottom-tab/sort-pill icons use). The v97 fix only changed color.

Matrix intake bot — ships on the Spark (bot-only, NOT the s9pk):
- Approve/reject now redacts the whole intake thread (card + ack + main-timeline nudge +
  the user's own photo/note), mirroring the email-review room; redact_thread takes the
  room as an arg and matches replies by m.thread OR m.in_reply_to (so the nudge clears).
  No more in-Matrix confirmation after a commit (the thread vanishing is the ack).
  Needs the bot to hold a redact/moderator power level in the intake room.
- New one-time backend/matrix_intake/redact_intake.py clears the room's pre-existing
  backlog (dry-run default; --apply).

Tests 42/42 green; frontend render-smoke green. Frontend fixes are inspection + render
-smoke verified (on-device confirm pending); the bot redaction is live-smoke only.
2026-06-20 12:32:56 -05:00

8.4 KiB

Plan: in-app business-card intake (mobile camera → transcribe → approve)

Status: PLAN — awaiting Grant's go-ahead before building (requested 2026-06-20). Goal: a camera button in the mobile top bar (left of the quick-log pencil) → take a photo or pick one from the library → the same vision-transcribe → parse → fuzzy-match → edit/approve/reject flow the Matrix card intake runs (M3), surfaced as an inline mobile sheet instead of a Matrix thread.

Why this is cheaper than it looks

The reusable core is already nio-free and already reachable from the CRM monolith:

  • server.py already imports the Spark client: sys.path.insert(.../ingest); import llm (server.py:6485). llm.chat_vision (multimodal → Spark Control passthrough) is the exact call the bot's card OCR uses.
  • backend/matrix_intake/parse.py imports only json, re, spark; spark.py imports only os, sys, llm. Neither imports matrix-nio. So server.py can import parse + spark the same way it imports llm, with no bot refactor and no matrix-nio in the CRM.
  • Match (find_intake_match / find_intake_candidates) and the write (/api/fundraising/log-communication, create-if-missing + contact upsert + audit) are already in server.py. The fuzzy matcher just got the generic-word fix (v-next).
  • The whole backend/ tree (incl. ingest/ + matrix_intake/) is COPY'd into the s9pk image (start9/0.4/Dockerfile:62), so the imports resolve on the box at runtime.

So this is one new endpoint + one new mobile component, reusing everything downstream. No migration, no new dependency, no change to the live Matrix bot.

Architecture decision

Recommended (Option A): server.py imports matrix_intake.parse + matrix_intake.spark directly. Add backend/matrix_intake to sys.path (guarded like the existing ingest insert) and import parse, spark. Pros: zero bot churn, reuses the tested transcribe + email-integrity parse verbatim, no divergence. Con: the CRM now imports two modules out of the bot package — a mild coupling, but they're pure (no nio, no CRM HTTP; we do NOT import the bot's crm_client).

Alternative (Option B): extract a shared backend/intake_core/ (vision.py = transcribe, extract.py = parse/normalize/revise) that both the bot and server.py import. Cleaner ownership, but it refactors the live, working bot for no functional gain right now. Defer unless the coupling in A bites later (e.g. if the bot's parse grows nio-coupled helpers).

Module-name caution (from the matrix-intake guide): the bot imports by bare name (import spark, import llm, import config). server.py already has ingest/ on its path (so llm, config, http_util resolve). Adding matrix_intake/ lets parse/spark resolve with no name overlap against ingest's modules — verified. Import only parse + spark (not crm_client/settings/bot).

Server: one new endpoint

POST /api/intake/card — authenticated member+ (it's a UI feature for the team; NOT bot-gated, NOT admin-only). Body is JSON, image as base64 (no multipart parsing in the stdlib handler):

{ "image_b64": "<base64>", "mime": "image/jpeg" }
→ 200 { "transcription": "...",
        "proposal": { investor_name, contact_name, contact_email, contact_title,
                      city, linkedin_url, phone, mobile, note },
        "match": { id, name } | null,
        "candidates": [ { id, name, score, matched_on }, ... ] }

Handler flow (mirrors bot.handle_cardhandle_intake, minus Matrix):

  1. Auth (reuse the standard authenticated gate).
  2. Decode + size-guard the base64; default mime image/jpeg.
  3. transcription = spark.transcribe_card(image_b64, mime) (Spark Control vision). On error → 502 { ok:false, reason:"vision_unavailable" }; on <5 chars → 200 { ok:false, reason:"unreadable" }.
  4. proposal = parse.parse_message("New investor — from a business card:\n"+transcription, roster=None) — the email-integrity rule rides along (an address/phone/LinkedIn is kept only if it literally appears in the transcription, never minted), exactly as in Matrix. roster=None for v1 (the team-roster framing is a Spark-side env; the box can pass None — prior parse behavior).
  5. match = find_intake_match(...), candidates = find_intake_candidates(...) from the proposal's investor_name/contact_email.
  6. Return { transcription, proposal, match, candidates }.

Approve reuses the existing write — the mobile sheet POSTs the (possibly-edited) proposal to POST /api/fundraising/log-communication with create_investor_if_missing (new) or the picked _match_id/candidate row (existing), tagged source="app_card" (a new provenance value distinct from matrix_card/matrix_intake). Reject is client-only (discard the sheet) — no server call, nothing written.

No NL-edit for v1. In a form UI, editing the fields directly is easier than chatting "change the email to…". The fields are inline-editable in the sheet. (A /api/intake/card/revise wrapping parse.revise is a possible later enhancement if Grant wants conversational corrections.)

Mobile UI: one new component

MobileCardCapture in the shell top bar, left of MobileQuickLog (the cluster at index.html:14609). Reuses <BottomSheet>, .sheet-input, StageChip, and the New-investor sheet patterns (8g).

  • Trigger: a camera-icon button + a hidden <input type="file" accept="image/*">omit capture so iOS shows the native action sheet (Take Photo / Photo Library / Browse), satisfying "take a photo OR use an existing photo." (capture="environment" would force the camera and skip the library — not what we want.)
  • On select: read the file as a data URL; downscale via <canvas> to ~2000px max dimension before base64 (native, no library) — keeps the payload under the StartOS reverse-proxy body cap (the matrix-intake guide's known 413 risk) and the model downscales to ~2 MP anyway. Don't over-shrink (hurts OCR). Show a "📇 Reading the card…" loading state.
  • POST to /api/intake/card; on unreadable/vision_unavailable show a "try a clearer, well-lit, fill-the-frame photo" message with a Retake button (same UX as the bot's 📇 replies).
  • Approval sheet (the New-investor sheet, pre-filled from proposal): editable investor name + contact name/email/title/city/phone/mobile/LinkedIn; a small "Existing investor?" block when match/candidates came back (pick a candidate row, or "Add as new"); Approve → the log-communication write; Reject/Retake → discard.
  • Icon: reuse the proven .quicklog-btn svg sizing fix (explicit CSS width/height + flex:none) so the new camera SVG doesn't hit the same iOS sole-flex-child collapse.

Scope / effort / risk

  • Server: ~1 endpoint + the parse/spark import guard + an app_card source tag. Small.
  • Frontend: ~1 component (camera button + capture input + canvas downscale + approval sheet) + CSS for the button. Medium (the approval sheet is the New-investor sheet pre-filled).
  • No migration, no new dependency, no Matrix-bot change.
  • Tests: an endpoint test that stubs spark.transcribe_card (like test_spark.py does) and asserts the transcribe→parse→match shape + the email-integrity passthrough; the real vision/OCR path stays live-smoke only (same as Matrix M3). Frontend is inspection + on-device.
  • Risks: (1) large-image upload vs the reverse-proxy cap — mitigated by client-side canvas downscale; (2) iOS file-input behavior across Safari/standalone-PWA — verify on-device; (3) OCR accuracy is the shared Matrix limitation (resolution-bound; "fill the frame"), not new here; (4) the parse/spark import must be lazy/guarded so a dev ./start.sh without Spark reachable still boots (the endpoint just 502s) — mirror the existing lazy import llm at server.py:6485.

Open questions for Grant (resolve before building)

  1. Provenance tagsource="app_card" OK (distinct from matrix_card)?
  2. v1 = form-field edits only (no conversational NL-edit on the card) — agreed? NL-edit can follow if wanted.
  3. Who can use it — any authenticated member (recommended), or restrict?
  4. Ships in the s9pk (server endpoint + frontend) — so it needs a version bump + build + install, unlike the bot-only M3. Confirm that's the intended delivery path.