a917280bbb
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.
129 lines
8.4 KiB
Markdown
129 lines
8.4 KiB
Markdown
# 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_card` → `handle_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 tag** — `source="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.
|