Files
ten31-database/backend/matrix_intake/spark.py
T
Keysat e824ff2206 Capture phone (office) + mobile (cell) on card intake; ship v0.1.0:98
Completes business-card contact capture. The transcription prompt now labels
Phone/Mobile/Fax on separate lines, and the extractor maps an office/main number ->
phone and a cell -> mobile, never a fax. Both carry the same digit-in-source
integrity rule as email/LinkedIn: a number is kept only if its digits literally
appear in the source (or, on revise, the instruction) -- never minted. The proposal
card shows Phone + Mobile and they're editable (aliases phone/tel/office, mobile/cell).

Server: _upsert_contact_from_fundraising now accepts contact.phone + contact.mobile
and writes them to the canonical contact record (contact-level, not grid pills),
shipped in s9pk v0.1.0:98. No schema change -- the contacts columns already exist.

41/41 backend suite green + the matrix_intake units; card flow end-to-end is live-smoke.
2026-06-20 11:26:39 -05:00

57 lines
3.2 KiB
Python

"""Thin reuse of the in-repo local-Qwen client (backend/ingest/llm.py) via Spark Control.
We import the ingest client rather than re-implementing the HTTP call so the intake bot
speaks the exact same Spark contract (model, /v1/chat/completions, TLS verify, .env load).
The intake message is real LP substance, but it goes ONLY to the local Qwen on Ten31 infra
— never Claude — so no scrub boundary applies (same basis as the daily digest). Never call a
Spark directly; everything goes through SPARK_CONTROL_URL.
"""
import os
import sys
_INGEST = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ingest")
if _INGEST not in sys.path:
sys.path.insert(0, _INGEST)
import llm # noqa: E402 (backend/ingest/llm.py — chat / chat_json over Spark Control)
def parse_json(prompt, system=None, max_tokens=400):
"""Send to local Qwen (temp 0, thinking off) and parse the first JSON object, or None."""
return llm.chat_json(prompt, system=system, max_tokens=max_tokens)
# The vision model only TRANSCRIBES the card; the existing text-parse flow then extracts the
# structured proposal from that transcription. Keeping the two steps separate (vs. asking the
# vision model for JSON directly) is deliberate: the transcription becomes the source text the
# email-integrity check runs against, so the "only keep an address that literally appears in the
# source, never let the model mint one" rule (parse.normalize) protects card intake too.
CARD_SYSTEM = (
"You are transcribing a photo of a business card. Copy the text EXACTLY as printed — never "
"paraphrase, translate, complete, normalize, or correct anything.\n"
"Read each of these character-by-character and reproduce every glyph precisely. Do NOT 'fix' "
"them toward a more common spelling or a well-known company's domain, and never add or drop a "
"character:\n"
" - Email: check the local part, the @, and the domain separately (transcribe 'mara.com' as "
"'mara.com', never 'marac.com').\n"
" - Phone, cell/mobile, and fax numbers — keep each on its own labeled line so they aren't "
"confused (put an office/main/direct number on Phone:, a cell/mobile number on Mobile:, and a "
"fax on Fax:).\n"
" - Website / LinkedIn URL.\n"
"Then list, each on its own labeled line and ONLY if present on the card:\n"
" Name: Title: Company: Email: Phone: Mobile: Fax: LinkedIn: City:\n"
"If a character is genuinely ambiguous, give your single best reading — never invent extra "
"characters to fill a gap. If the image is not a readable business card, reply with the single "
"word NONE. Output only the labeled lines, nothing else."
)
def transcribe_card(image_b64, mime="image/jpeg", chat_fn=None):
"""Vision-transcribe a business card to faithful text via the local VL model (same model and
Spark Control endpoint as the text parse). Returns the transcription string, or '' if the model
saw no readable card. `chat_fn` is injectable for offline tests (defaults to Spark/VL)."""
chat_fn = chat_fn or llm.chat_vision
out = (chat_fn("Transcribe this business card.", image_b64, mime=mime,
system=CARD_SYSTEM, max_tokens=600) or "").strip()
return "" if out.upper() == "NONE" else out