Selbst gehostete PWA, die Visitenkarten von einem Foto freistellt, ausliest und als Kontakt bereitstellt. - Stapelscan: OpenCV findet die Kartenrechtecke über mehrere Binärmasken, entzerrt sie perspektivisch und schneidet sie einzeln aus. Ohne Fund gilt das ganze Foto als eine Karte. - Extraktion: ein Aufruf je Zuschnitt an das Vision-Modell mit JSON-Schema. Kein vorgeschaltetes OCR - das würde Layout und Schriftgrößen wegwerfen, aus denen die Feldzuordnung entsteht. - Metadaten: Aufnahmezeit und GPS aus den EXIF-Daten des Fotos, Ortsname über Nominatim, Browserstandort nur als Rückfallebene. - Übersicht mit Volltextsuche und Filtern, Detailansicht mit Korrekturmaske. - Notizfeld je Karte, Erinnerungen per Mail inklusive Nachholen verpasster Termine nach einem Neustart. - vCard 3.0 einzeln und als Sammeldatei, Karte gilt danach als exportiert. - Anmeldung über ein Passwort, Sitzung als signiertes Cookie. - Deployment per Tag-Push nach den Konventionen in DEPLOY.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
5.1 KiB
Python
160 lines
5.1 KiB
Python
"""Kontaktdaten aus einem Kartenbild lesen.
|
|
|
|
Das Bild geht direkt an das Vision-Modell: Layout, Schriftgroesse und
|
|
Gruppierung sind genau die Information, die die Feldzuordnung moeglich macht
|
|
- ein vorgeschalteter OCR-Schritt wuerde sie wegwerfen.
|
|
"""
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import logging
|
|
|
|
import anthropic
|
|
|
|
from . import config
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
FIELDS = [
|
|
"full_name", "first_name", "last_name", "job_title", "company", "department",
|
|
"email", "phone", "mobile", "fax", "website",
|
|
"street", "postal_code", "city", "country",
|
|
]
|
|
|
|
SCHEMA = {
|
|
"type": "object",
|
|
"properties": {
|
|
"is_business_card": {
|
|
"type": "boolean",
|
|
"description": "False, wenn der Ausschnitt keine Visitenkarte zeigt.",
|
|
},
|
|
**{name: {"type": ["string", "null"]} for name in FIELDS},
|
|
},
|
|
"required": ["is_business_card", *FIELDS],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
SYSTEM = """Du liest Visitenkarten aus und gibst die Kontaktdaten strukturiert zurueck.
|
|
|
|
Regeln:
|
|
- Uebernimm nur, was auf der Karte steht. Nichts ergaenzen, nichts raten.
|
|
- Fehlt ein Feld auf der Karte, gib null zurueck.
|
|
- Telefonnummern im internationalen Format, wenn die Vorwahl erkennbar ist
|
|
(z. B. +49 511 123456), sonst unveraendert.
|
|
- phone ist die Festnetznummer, mobile die Mobilnummer. Ist nur eine Nummer
|
|
ohne Kennzeichnung angegeben, gehoert sie in phone.
|
|
- company ist der Firmenname, job_title die Funktion der Person.
|
|
- full_name ist der vollstaendige Name inklusive Titeln wie "Dr.",
|
|
first_name und last_name ohne Titel.
|
|
- website ohne Protokollpraefix.
|
|
- Zeigt der Ausschnitt keine Visitenkarte (Tischkante, Notizzettel, leere
|
|
Flaeche), setze is_business_card auf false und alle Felder auf null."""
|
|
|
|
PROMPT = "Lies diese Visitenkarte aus."
|
|
|
|
_client = None
|
|
|
|
|
|
def available() -> bool:
|
|
return bool(config.ANTHROPIC_API_KEY)
|
|
|
|
|
|
def _get_client() -> anthropic.AsyncAnthropic:
|
|
global _client
|
|
if _client is None:
|
|
_client = anthropic.AsyncAnthropic(api_key=config.ANTHROPIC_API_KEY)
|
|
return _client
|
|
|
|
|
|
def _empty(reason: str) -> dict:
|
|
return {"is_business_card": False, "_error": reason, **{f: None for f in FIELDS}}
|
|
|
|
|
|
def _has_content(result: dict) -> bool:
|
|
return any(result.get(field) for field in FIELDS)
|
|
|
|
|
|
async def _ask(image_jpeg: bytes) -> dict:
|
|
client = _get_client()
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "image/jpeg",
|
|
"data": base64.standard_b64encode(image_jpeg).decode(),
|
|
},
|
|
},
|
|
{"type": "text", "text": PROMPT},
|
|
],
|
|
}
|
|
]
|
|
common = dict(
|
|
model=config.ANTHROPIC_MODEL,
|
|
max_tokens=2000,
|
|
system=SYSTEM,
|
|
messages=messages,
|
|
output_config={
|
|
"format": {"type": "json_schema", "schema": SCHEMA},
|
|
"effort": "low", # reines Ablesen, keine Denkarbeit
|
|
},
|
|
)
|
|
try:
|
|
response = await client.beta.messages.create(
|
|
betas=["server-side-fallback-2026-07-01"],
|
|
fallbacks="default",
|
|
**common,
|
|
)
|
|
except anthropic.BadRequestError:
|
|
# Der Fallback-Parameter haengt am Modell. Ohne ihn laeuft die Anfrage
|
|
# weiter, sie bricht bei einer Ablehnung nur ohne Ersatzmodell ab.
|
|
log.warning("Server-side fallback abgelehnt, Anfrage ohne Fallback")
|
|
response = await client.messages.create(**common)
|
|
|
|
if response.stop_reason == "refusal":
|
|
return _empty("Vom Modell abgelehnt")
|
|
|
|
text = next((b.text for b in response.content if b.type == "text"), None)
|
|
if not text:
|
|
return _empty("Leere Antwort")
|
|
return json.loads(text)
|
|
|
|
|
|
async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict:
|
|
"""Ein Kartenbild auslesen.
|
|
|
|
Bleibt das Ergebnis leer, wird einmal um 180 Grad gedreht nachgefasst:
|
|
wie herum die Karte auf dem Tisch lag, ist geometrisch nicht bestimmbar.
|
|
"""
|
|
if not available():
|
|
return _empty("Kein ANTHROPIC_API_KEY gesetzt")
|
|
try:
|
|
result = await _ask(image_jpeg)
|
|
except Exception as exc: # Netzfehler, Rate Limit, ungueltiges JSON
|
|
log.exception("Extraktion fehlgeschlagen")
|
|
return _empty(f"{type(exc).__name__}: {exc}")
|
|
|
|
if not _has_content(result) and rotated_jpeg is not None:
|
|
try:
|
|
retry = await _ask(rotated_jpeg)
|
|
if _has_content(retry):
|
|
retry["_rotated"] = True
|
|
return retry
|
|
except Exception:
|
|
log.exception("Zweiter Versuch (gedreht) fehlgeschlagen")
|
|
return result
|
|
|
|
|
|
async def extract_all(pairs: list) -> list:
|
|
"""Alle Karten eines Fotos parallel auslesen."""
|
|
limit = asyncio.Semaphore(max(1, config.EXTRACT_CONCURRENCY))
|
|
|
|
async def one(upright: bytes, rotated: bytes) -> dict:
|
|
async with limit:
|
|
return await extract(upright, rotated)
|
|
|
|
return await asyncio.gather(*(one(u, r) for u, r in pairs))
|