"""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