Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f29d5eb27 | ||
|
|
bc1981f1bd | ||
|
|
30db433c9a | ||
|
|
208aadc9bc |
@@ -2,7 +2,6 @@
|
||||
.gitea
|
||||
.venv
|
||||
data
|
||||
tests
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.log
|
||||
|
||||
@@ -16,15 +16,13 @@ jobs:
|
||||
with:
|
||||
github-server-url: http://gitea:3000
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Abhaengigkeiten installieren
|
||||
run: pip install -r requirements-dev.txt
|
||||
|
||||
# Kein setup-python: die Action laedt ihre Runtime von GitHub-Releases
|
||||
# und scheitert auf dem Gitea-Runner. Stattdessen laufen die Tests in
|
||||
# einer eigenen Stufe des Dockerfiles - gegen dieselbe Python-Version
|
||||
# und dieselben Pakete wie die Produktion. Ein roter Lauf bricht hier
|
||||
# ab, bevor irgendetwas auf dem Server angefasst wird.
|
||||
- name: Tests
|
||||
run: pytest -q
|
||||
run: docker build --target test .
|
||||
|
||||
- name: Tag ermitteln
|
||||
run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
|
||||
@@ -149,7 +149,7 @@ Neuer Proxy Host:
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Domain Names | `business-card-scanner.lucas-orth.de` |
|
||||
| Domain Names | `scanner.lucas-orth.de` |
|
||||
| Scheme | `http` |
|
||||
| Forward Hostname / IP | `business-card-scanner` (= `container_name`) |
|
||||
| Forward Port | `8080` |
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -1,17 +1,31 @@
|
||||
# Abhaengigkeiten in ein venv bauen, damit der Compiler-Ballast nicht ins
|
||||
# fertige Image wandert.
|
||||
FROM python:3.12-slim AS build
|
||||
WORKDIR /app
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Testlauf gegen dieselbe Python-Version und dieselben Pakete wie die
|
||||
# Produktion. Die Stufe haengt nicht am finalen Image und wird bei einem
|
||||
# normalen Build uebersprungen; der Workflow ruft sie mit --target test auf.
|
||||
FROM build AS test
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements-dev.txt .
|
||||
RUN pip install --no-cache-dir -r requirements-dev.txt
|
||||
COPY app ./app
|
||||
COPY static ./static
|
||||
COPY tests ./tests
|
||||
RUN pytest -q
|
||||
|
||||
FROM python:3.12-slim
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PATH="/opt/venv/bin:$PATH" \
|
||||
DATA_DIR=/data \
|
||||
PORT=8080
|
||||
DATA_DIR=/data
|
||||
|
||||
# libglib2.0-0 wird von opencv-python-headless gebraucht.
|
||||
RUN apt-get update \
|
||||
|
||||
@@ -56,7 +56,7 @@ Tag-Push nach den Konventionen in [DEPLOY.md](DEPLOY.md). Vor dem ersten Tag:
|
||||
nach dem Muster in [.env.example](.env.example). `SECRET_KEY` mit
|
||||
`openssl rand -hex 32` erzeugen.
|
||||
2. **Proxy Host** im Nginx Proxy Manager:
|
||||
`business-card-scanner.lucas-orth.de` → `business-card-scanner` : `8080`,
|
||||
`scanner.lucas-orth.de` → `business-card-scanner` : `8080`,
|
||||
danach Let's-Encrypt-Zertifikat und „Force SSL“.
|
||||
3. **Deployen:** `git tag v1.0.0 && git push origin v1.0.0`
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import logging
|
||||
|
||||
import anthropic
|
||||
|
||||
from . import config
|
||||
from . import config, imaging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,9 +52,22 @@ Regeln:
|
||||
|
||||
PROMPT = "Lies diese Visitenkarte aus."
|
||||
|
||||
# Nicht jedes Modell nimmt jeden Parameter an: die kleinen Modelle kennen
|
||||
# output_config.effort nicht (400), der server-seitige Fallback gilt nur fuer
|
||||
# die groessten.
|
||||
_NO_EFFORT = ("claude-haiku-", "claude-sonnet-4-5")
|
||||
_WITH_FALLBACK = ("claude-opus-5", "claude-fable-")
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def _output_config(with_effort: bool) -> dict:
|
||||
settings = {"format": {"type": "json_schema", "schema": SCHEMA}}
|
||||
if with_effort:
|
||||
settings["effort"] = "low" # reines Ablesen, keine Denkarbeit
|
||||
return settings
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return bool(config.ANTHROPIC_API_KEY)
|
||||
|
||||
@@ -92,27 +105,28 @@ async def _ask(image_jpeg: bytes) -> dict:
|
||||
],
|
||||
}
|
||||
]
|
||||
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
|
||||
},
|
||||
)
|
||||
model = config.ANTHROPIC_MODEL
|
||||
common = dict(model=model, max_tokens=2000, system=SYSTEM, messages=messages)
|
||||
effort = not model.startswith(_NO_EFFORT)
|
||||
try:
|
||||
response = await client.beta.messages.create(
|
||||
betas=["server-side-fallback-2026-07-01"],
|
||||
fallbacks="default",
|
||||
**common,
|
||||
if model.startswith(_WITH_FALLBACK):
|
||||
response = await client.beta.messages.create(
|
||||
betas=["server-side-fallback-2026-07-01"],
|
||||
fallbacks="default",
|
||||
output_config=_output_config(effort),
|
||||
**common,
|
||||
)
|
||||
else:
|
||||
response = await client.messages.create(
|
||||
output_config=_output_config(effort), **common
|
||||
)
|
||||
except anthropic.BadRequestError as exc:
|
||||
# Ein Modell mit engerem Parametersatz, als die Listen oben kennen.
|
||||
# Einmal ohne Zusatzparameter nachfassen, statt die Karte zu verlieren.
|
||||
log.warning("Anfrage abgelehnt (%s) - Wiederholung ohne Zusatzparameter", exc)
|
||||
response = await client.messages.create(
|
||||
output_config=_output_config(with_effort=False), **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")
|
||||
@@ -123,11 +137,12 @@ async def _ask(image_jpeg: bytes) -> dict:
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict:
|
||||
async def extract(image_jpeg: bytes) -> 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.
|
||||
Die gedrehte Fassung entsteht erst hier, nicht auf Vorrat fuer jede Karte.
|
||||
"""
|
||||
if not available():
|
||||
return _empty("Kein ANTHROPIC_API_KEY gesetzt")
|
||||
@@ -137,9 +152,10 @@ async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict:
|
||||
log.exception("Extraktion fehlgeschlagen")
|
||||
return _empty(f"{type(exc).__name__}: {exc}")
|
||||
|
||||
if not _has_content(result) and rotated_jpeg is not None:
|
||||
if not _has_content(result):
|
||||
try:
|
||||
retry = await _ask(rotated_jpeg)
|
||||
rotated = await asyncio.to_thread(imaging.rotate_jpeg_180, image_jpeg)
|
||||
retry = await _ask(rotated)
|
||||
if _has_content(retry):
|
||||
retry["_rotated"] = True
|
||||
return retry
|
||||
@@ -148,12 +164,12 @@ async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
async def extract_all(pairs: list) -> list:
|
||||
async def extract_all(images: 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 def one(image_jpeg: bytes) -> dict:
|
||||
async with limit:
|
||||
return await extract(upright, rotated)
|
||||
return await extract(image_jpeg)
|
||||
|
||||
return await asyncio.gather(*(one(u, r) for u, r in pairs))
|
||||
return await asyncio.gather(*(one(image) for image in images))
|
||||
|
||||
@@ -79,3 +79,12 @@ def encode_jpeg(rgb_or_bgr: np.ndarray, quality: int = 88, bgr: bool = True) ->
|
||||
Image.fromarray(array).save(buffer, format="JPEG", quality=quality, optimize=True)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def rotate_jpeg_180(raw: bytes) -> bytes:
|
||||
"""Nur fuer den zweiten Anlauf einer leer gebliebenen Karte gedacht -
|
||||
deshalb aus dem JPEG heraus statt aus dem grossen Rohbild."""
|
||||
image = Image.open(io.BytesIO(raw)).transpose(Image.ROTATE_180)
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=88, optimize=True)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
22
app/main.py
22
app/main.py
@@ -6,7 +6,6 @@ from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -104,17 +103,16 @@ def logout(response: Response) -> dict:
|
||||
# ------------------------------------------------------------------------------ Scan
|
||||
|
||||
def _process_photo(raw: bytes) -> tuple:
|
||||
"""Rechenlastiger Teil: dekodieren, freistellen, als JPEG kodieren."""
|
||||
"""Rechenlastiger Teil: dekodieren, freistellen, als JPEG kodieren.
|
||||
|
||||
Die Zuschnitte werden einzeln kodiert und sofort freigegeben - bei zwanzig
|
||||
Karten liegen sonst rund 70 MB Rohbild gleichzeitig im Speicher.
|
||||
"""
|
||||
meta = imaging.read_metadata(raw)
|
||||
image = imaging.decode(raw)
|
||||
crops, fallback = segment.segment(image)
|
||||
encoded = [
|
||||
(
|
||||
imaging.encode_jpeg(crop),
|
||||
imaging.encode_jpeg(cv2.rotate(crop, cv2.ROTATE_180)),
|
||||
)
|
||||
for crop in crops
|
||||
]
|
||||
crops, fallback = segment.segment(imaging.decode(raw))
|
||||
encoded = []
|
||||
while crops:
|
||||
encoded.append(imaging.encode_jpeg(crops.pop(0)))
|
||||
return meta, encoded, fallback
|
||||
|
||||
|
||||
@@ -152,7 +150,7 @@ async def scan(
|
||||
created = now_iso()
|
||||
source = "einzel" if fallback else "stapel"
|
||||
cards = []
|
||||
for (upright, _), result in zip(encoded, results):
|
||||
for upright, result in zip(encoded, results):
|
||||
card_id = uuid.uuid4().hex
|
||||
image_file = f"{card_id}.jpg"
|
||||
(config.IMAGE_DIR / image_file).write_bytes(upright)
|
||||
|
||||
@@ -11,7 +11,8 @@ import numpy as np
|
||||
CARD_RATIO = 85.6 / 54.0
|
||||
RATIO_MIN, RATIO_MAX = 1.25, 2.05
|
||||
|
||||
OUT_WIDTH = 1400
|
||||
OUT_WIDTH = 1400 # Obergrenze; kleinere Karten werden nicht hochskaliert
|
||||
MIN_WIDTH = 600
|
||||
OUT_HEIGHT = round(OUT_WIDTH / CARD_RATIO)
|
||||
|
||||
DETECT_EDGE = 1600 # Aufloesung, auf der gesucht wird
|
||||
@@ -129,19 +130,32 @@ def segment(image: np.ndarray) -> tuple[list[np.ndarray], bool]:
|
||||
crops = []
|
||||
for rect in _reading_order(rects):
|
||||
quad = _order_quad(cv2.boxPoints(rect)) / scale # zurueck auf volle Aufloesung
|
||||
width, height = _target_size(quad)
|
||||
target = np.array(
|
||||
[[0, 0], [OUT_WIDTH, 0], [OUT_WIDTH, OUT_HEIGHT], [0, OUT_HEIGHT]],
|
||||
dtype="float32",
|
||||
[[0, 0], [width, 0], [width, height], [0, height]], dtype="float32"
|
||||
)
|
||||
matrix = cv2.getPerspectiveTransform(quad, target)
|
||||
crops.append(
|
||||
cv2.warpPerspective(
|
||||
image, matrix, (OUT_WIDTH, OUT_HEIGHT), flags=cv2.INTER_CUBIC
|
||||
)
|
||||
cv2.warpPerspective(image, matrix, (width, height), flags=cv2.INTER_CUBIC)
|
||||
)
|
||||
return crops, False
|
||||
|
||||
|
||||
def _target_size(quad: np.ndarray) -> tuple:
|
||||
"""Zuschnitt so gross wie die Vorlage, hoechstens OUT_WIDTH.
|
||||
|
||||
Auf einem Stapelfoto ist eine Karte nur ein paar hundert Pixel breit.
|
||||
Sie auf 1400 hochzurechnen erzeugt vier Mal so viele Pixel ohne ein
|
||||
Quentchen mehr Information - und kostet Speicher, Bildgroesse und
|
||||
Bildtokens beim Modellaufruf.
|
||||
"""
|
||||
long_edge = max(
|
||||
np.linalg.norm(quad[1] - quad[0]), np.linalg.norm(quad[2] - quad[3])
|
||||
)
|
||||
width = int(min(OUT_WIDTH, max(MIN_WIDTH, round(long_edge))))
|
||||
return width, round(width / CARD_RATIO)
|
||||
|
||||
|
||||
def _fit_whole(image: np.ndarray) -> np.ndarray:
|
||||
"""Ohne Fund: das ganze Bild als eine Karte behandeln."""
|
||||
height, width = image.shape[:2]
|
||||
|
||||
@@ -12,6 +12,11 @@ services:
|
||||
restart: unless-stopped
|
||||
# Wird im Workflow aus dem Secret DOTENV erzeugt.
|
||||
env_file: .env
|
||||
# Deckel gegen Ausreisser: ein Stapelscan braucht im Normalfall deutlich
|
||||
# unter 500 MB. Laeuft er aus dem Ruder, stirbt der Container - nicht der
|
||||
# Host, auf dem auch Jitsi und die Datenbank liegen.
|
||||
mem_limit: 1g
|
||||
cpus: 1.5
|
||||
volumes:
|
||||
- business-card-scanner-data:/data
|
||||
networks:
|
||||
|
||||
@@ -111,6 +111,20 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** Zustand einer Karte als abgestufte Pillen: schwarz, was erledigt ist,
|
||||
* grau umrandet, was noch offen oder auffaellig ist. */
|
||||
function cardTags(card) {
|
||||
const tags = [];
|
||||
if (card.place) tags.push(["", card.place]);
|
||||
tags.push(
|
||||
card.exported_at ? ["pill-solid", "exportiert"] : ["", "nicht exportiert"]
|
||||
);
|
||||
if (card.extract_status !== "ok") tags.push(["", "Lesefehler"]);
|
||||
return tags
|
||||
.map(([extra, text]) => `<span class="pill ${extra}">${esc(text)}</span>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- Routing
|
||||
|
||||
function go(route, cardId) {
|
||||
@@ -167,7 +181,7 @@
|
||||
<button class="row" data-card="${esc(card.id)}">
|
||||
<span class="row-head">
|
||||
<span class="row-title">${esc(displayName(card))}</span>
|
||||
<span class="row-meta">${card.has_contact ? "OK" : "Leer"}</span>
|
||||
<span class="row-meta">${card.has_contact ? "Gelesen" : "Leer"}</span>
|
||||
</span>
|
||||
<span class="row-sub">${esc(card.company || card.email || "Keine Daten erkannt")}</span>
|
||||
</button>`
|
||||
@@ -234,15 +248,7 @@
|
||||
<span class="row-sub">
|
||||
${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
|
||||
</span>
|
||||
<span class="row-sub">${esc(
|
||||
[
|
||||
card.place,
|
||||
card.exported_at ? "exportiert" : "nicht exportiert",
|
||||
card.extract_status !== "ok" ? "Lesefehler" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
)}</span>
|
||||
<span class="row-tags">${cardTags(card)}</span>
|
||||
</button>`
|
||||
)
|
||||
.join("")}
|
||||
@@ -367,7 +373,7 @@
|
||||
</form>
|
||||
|
||||
<div class="section-label"><span class="label">Karte entfernen</span></div>
|
||||
<button class="btn btn-danger" data-delete-card="${esc(card.id)}">
|
||||
<button class="btn btn-quiet" data-delete-card="${esc(card.id)}">
|
||||
Endgültig löschen <span class="arrow">×</span>
|
||||
</button>`;
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
Foto aufnehmen <span class="arrow">→</span>
|
||||
<input id="input-camera" type="file" accept="image/*" capture="environment" hidden>
|
||||
</label>
|
||||
<label class="btn" for="input-library">
|
||||
<label class="btn btn-quiet" for="input-library">
|
||||
Aus Fotos wählen <span class="arrow">↗</span>
|
||||
<input id="input-library" type="file" accept="image/*" hidden>
|
||||
</label>
|
||||
|
||||
345
static/style.css
345
static/style.css
@@ -1,14 +1,24 @@
|
||||
/* Schwarzweiss, harte Kanten, keine Rundungen. Mobile first. */
|
||||
/* Schwarzweiss mit Grau als Hierarchiestufe. Harte Kanten, keine Rundungen.
|
||||
Mobile first.
|
||||
|
||||
Drei Ebenen, konsequent durchgehalten:
|
||||
Flaeche weiss -> --fill (gruppiert) -> schwarz (das eine Wichtige)
|
||||
Text --ink -> --ink-2 (erlaeuternd) -> --ink-3 (Beiwerk)
|
||||
Linie 3px schwarz (traegt) -> 1px schwarz (gliedert) -> --rule (trennt) */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--fg: #000;
|
||||
|
||||
--ink: #000;
|
||||
--ink-2: #4a4a4a;
|
||||
--ink-3: #8a8a8a;
|
||||
|
||||
--bg: #fff;
|
||||
--muted: #6b6b6b;
|
||||
--hair: #d5d5d5;
|
||||
--wash: #f2f2f2;
|
||||
--fill: #f4f4f4;
|
||||
--page: #e4e4e4;
|
||||
--rule: #d4d4d4;
|
||||
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--shell: 3px;
|
||||
--pad: 18px;
|
||||
}
|
||||
|
||||
@@ -17,8 +27,8 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--wash);
|
||||
color: var(--fg);
|
||||
background: var(--page);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
@@ -42,36 +52,37 @@ a { color: inherit; }
|
||||
.display {
|
||||
font-weight: 800;
|
||||
font-stretch: 85%;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.025em;
|
||||
text-transform: uppercase;
|
||||
line-height: 0.95;
|
||||
line-height: 0.94;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.d-xl { font-size: 40px; }
|
||||
.d-lg { font-size: 28px; }
|
||||
.d-md { font-size: 20px; }
|
||||
.d-xl { font-size: 42px; }
|
||||
.d-lg { font-size: 30px; }
|
||||
.d-md { font-size: 19px; letter-spacing: -0.01em; }
|
||||
|
||||
.label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.muted { color: var(--ink-2); }
|
||||
.hint { font-size: 12.5px; line-height: 1.5; color: var(--ink-3); }
|
||||
.mono-num { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ---------------------------------------------------------------------- Gerüst */
|
||||
/* ---------------------------------------------------------------------- Geruest */
|
||||
|
||||
.app {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
border-left: 1px solid var(--fg);
|
||||
border-right: 1px solid var(--fg);
|
||||
border-left: 1px solid var(--ink);
|
||||
border-right: 1px solid var(--ink);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -83,83 +94,94 @@ a { color: inherit; }
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px var(--pad);
|
||||
padding: 13px var(--pad);
|
||||
background: var(--bg);
|
||||
border-bottom: var(--shell) solid var(--fg);
|
||||
border-bottom: 3px solid var(--ink);
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
background: var(--fg);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 32px;
|
||||
background: var(--ink);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.mark svg { width: 20px; height: 20px; }
|
||||
.mark svg { width: 19px; height: 19px; }
|
||||
|
||||
.wordmark { line-height: 1; }
|
||||
.wordmark .d-md { display: block; }
|
||||
.wordmark .label { color: var(--muted); margin-top: 3px; }
|
||||
.wordmark .label { color: var(--ink-3); margin-top: 4px; }
|
||||
|
||||
.topbar .spacer { margin-left: auto; }
|
||||
|
||||
main { flex: 1; display: flex; flex-direction: column; }
|
||||
.screen { flex: 1; padding: var(--pad) var(--pad) 28px; }
|
||||
.screen { flex: 1; padding: var(--pad) var(--pad) 30px; }
|
||||
.screen[hidden] { display: none; }
|
||||
|
||||
.stack > * + * { margin-top: 14px; }
|
||||
.stack > * + * { margin-top: 12px; }
|
||||
.stack-tight > * + * { margin-top: 8px; }
|
||||
|
||||
/* ---------------------------------------------------------------------- Blöcke */
|
||||
|
||||
.block {
|
||||
border: var(--shell) solid var(--fg);
|
||||
padding: 16px;
|
||||
}
|
||||
/* ---------------------------------------------------------------------- Bloecke */
|
||||
|
||||
/* Oberste Ebene: das eine Wichtige pro Ansicht. Nur einmal verwenden. */
|
||||
.block-invert {
|
||||
background: var(--fg);
|
||||
background: var(--ink);
|
||||
color: var(--bg);
|
||||
border: var(--shell) solid var(--fg);
|
||||
padding: 20px 18px;
|
||||
}
|
||||
|
||||
.block-invert .muted { color: #a9a9a9; }
|
||||
.block-invert .label { color: #a9a9a9; }
|
||||
|
||||
/* Mittlere Ebene: gruppiert, tritt zurueck. */
|
||||
.block {
|
||||
background: var(--fill);
|
||||
border: 1px solid var(--rule);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.block-invert .muted { color: #b4b4b4; }
|
||||
|
||||
/* Kopfband ueber einer Liste - ordnet zu, konkurriert aber nicht. */
|
||||
.strip {
|
||||
border: var(--shell) solid var(--fg);
|
||||
border-top: none;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 14px;
|
||||
background: var(--fill);
|
||||
border: 1px solid var(--ink);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.strip .label { color: var(--ink-2); }
|
||||
|
||||
.section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 26px 0 10px;
|
||||
gap: 12px;
|
||||
margin: 34px 0 12px;
|
||||
}
|
||||
|
||||
.section-label .label { color: var(--ink); }
|
||||
|
||||
.section-label::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--fg);
|
||||
background: var(--rule);
|
||||
}
|
||||
|
||||
.figures {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
margin-top: 18px;
|
||||
gap: 32px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
.figure { display: flex; flex-direction: column; }
|
||||
.figure .label { color: inherit; opacity: 0.7; margin-bottom: 5px; }
|
||||
.figure .label { margin-bottom: 6px; }
|
||||
|
||||
/* --------------------------------------------------------------------- Buttons */
|
||||
|
||||
@@ -171,7 +193,7 @@ main { flex: 1; display: flex; flex-direction: column; }
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: var(--bg);
|
||||
border: var(--shell) solid var(--fg);
|
||||
border: 2px solid var(--ink);
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -181,54 +203,81 @@ main { flex: 1; display: flex; flex-direction: column; }
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.btn-primary { background: var(--fg); color: var(--bg); }
|
||||
/* Was man hier tun soll. */
|
||||
.btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
|
||||
|
||||
/* Vorhanden, aber leise. */
|
||||
.btn-quiet {
|
||||
border: 1px solid var(--rule);
|
||||
color: var(--ink-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.btn .arrow { font-weight: 400; font-size: 17px; }
|
||||
|
||||
.btn-danger { border-color: var(--fg); color: var(--fg); }
|
||||
.btn-quiet .arrow { color: var(--ink-3); }
|
||||
|
||||
/* --------------------------------------------------------------------- Formular */
|
||||
|
||||
.field { display: block; }
|
||||
.field .label { color: var(--muted); margin-bottom: 5px; }
|
||||
.field .label { color: var(--ink-3); margin-bottom: 6px; }
|
||||
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field select {
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--fg);
|
||||
border: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field textarea:focus,
|
||||
.field select:focus { outline: 3px solid var(--fg); outline-offset: 1px; }
|
||||
.field textarea:focus { outline: 2px solid var(--ink); outline-offset: 1px; }
|
||||
|
||||
.field textarea { min-height: 90px; resize: vertical; }
|
||||
.field textarea { min-height: 88px; resize: vertical; }
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
border: var(--shell) solid var(--fg);
|
||||
/* iOS rendert Datumsfelder eigenwillig: der Wert sitzt zentriert, die Hoehe
|
||||
sackt ein, der Innenabstand kommt vom System. Das zieht sie auf die Form
|
||||
der uebrigen Felder. */
|
||||
.field input[type="datetime-local"] {
|
||||
min-height: 46px;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.search input { flex: 1; border: none; padding: 14px; background: var(--bg); }
|
||||
.field input[type="datetime-local"]::-webkit-date-and-time-value {
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.field input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.search { display: flex; border: 2px solid var(--ink); }
|
||||
.search input { flex: 1; border: none; padding: 13px 14px; background: var(--bg); }
|
||||
.search input:focus { outline: none; }
|
||||
|
||||
.search button {
|
||||
border: none;
|
||||
border-left: 2px solid var(--fg);
|
||||
background: var(--bg);
|
||||
border-left: 1px solid var(--rule);
|
||||
background: var(--fill);
|
||||
color: var(--ink-2);
|
||||
padding: 0 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||||
|
||||
/* Ungewaehlt zurueckgenommen, damit die Auswahl ueberhaupt auffaellt. */
|
||||
.chip {
|
||||
padding: 7px 11px;
|
||||
border: 2px solid var(--fg);
|
||||
border: 1px solid var(--rule);
|
||||
background: var(--bg);
|
||||
color: var(--ink-3);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
@@ -236,11 +285,15 @@ main { flex: 1; display: flex; flex-direction: column; }
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip[aria-pressed="true"] { background: var(--fg); color: var(--bg); }
|
||||
.chip[aria-pressed="true"] {
|
||||
background: var(--ink);
|
||||
border-color: var(--ink);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- Tabellen */
|
||||
/* --------------------------------------------------------------------- Listen */
|
||||
|
||||
.rows { border-top: 1px solid var(--fg); }
|
||||
.rows { border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); }
|
||||
|
||||
.row {
|
||||
display: block;
|
||||
@@ -248,30 +301,93 @@ main { flex: 1; display: flex; flex-direction: column; }
|
||||
text-align: left;
|
||||
background: var(--bg);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--hair);
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:last-child { border-bottom: 1px solid var(--fg); }
|
||||
.row-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.row-title { font-weight: 800; font-size: 16px; }
|
||||
.row-sub { color: var(--muted); font-size: 13px; margin-top: 2px; }
|
||||
.row-meta { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); white-space: nowrap; }
|
||||
.row:last-child { border-bottom: none; }
|
||||
|
||||
.row-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row-title { font-weight: 800; font-size: 17px; letter-spacing: -0.01em; }
|
||||
.row-sub { color: var(--ink-2); font-size: 13.5px; margin-top: 3px; }
|
||||
|
||||
.row-meta {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; }
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--rule);
|
||||
background: var(--bg);
|
||||
color: var(--ink-3);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pill-solid { background: var(--ink); border-color: var(--ink); color: var(--bg); }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--ink);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- Tabelle */
|
||||
|
||||
.data {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-top: 1px solid var(--ink);
|
||||
border-bottom: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.data tr:nth-child(even) { background: var(--fill); }
|
||||
.data th, .data td { padding: 10px 12px; text-align: left; vertical-align: top; }
|
||||
|
||||
.data th {
|
||||
width: 36%;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
padding-top: 13px;
|
||||
}
|
||||
|
||||
.data { width: 100%; border-collapse: collapse; }
|
||||
.data th, .data td { padding: 11px 0; border-bottom: 1px solid var(--hair); text-align: left; vertical-align: top; }
|
||||
.data th { width: 38%; font-weight: 400; color: var(--muted); font-size: 13px; }
|
||||
.data td { font-weight: 600; word-break: break-word; }
|
||||
.data tr:last-child th, .data tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ----------------------------------------------------------------------- Bilder */
|
||||
|
||||
.card-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: var(--shell) solid var(--fg);
|
||||
background: var(--wash);
|
||||
border: 1px solid var(--ink);
|
||||
background: var(--fill);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- Navigation */
|
||||
@@ -282,15 +398,16 @@ main { flex: 1; display: flex; flex-direction: column; }
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
background: var(--bg);
|
||||
border-top: var(--shell) solid var(--fg);
|
||||
border-top: 3px solid var(--ink);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 14px 4px calc(14px + env(safe-area-inset-bottom));
|
||||
padding: 12px 4px calc(12px + env(safe-area-inset-bottom));
|
||||
background: var(--bg);
|
||||
border: none;
|
||||
border-left: 1px solid var(--fg);
|
||||
border-left: 1px solid var(--rule);
|
||||
color: var(--ink-3);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
@@ -299,35 +416,47 @@ main { flex: 1; display: flex; flex-direction: column; }
|
||||
}
|
||||
|
||||
.tab:first-child { border-left: none; }
|
||||
.tab[aria-selected="true"] { background: var(--fg); color: var(--bg); }
|
||||
.tab .count { display: block; font-size: 16px; font-weight: 800; margin-bottom: 2px; }
|
||||
.tab[aria-selected="true"] { background: var(--ink); color: var(--bg); }
|
||||
|
||||
/* ------------------------------------------------------------------- Zustände */
|
||||
|
||||
.empty { border: 2px dashed var(--fg); padding: 28px 18px; text-align: center; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 7px;
|
||||
border: 2px solid var(--fg);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
.tab .count {
|
||||
display: block;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: var(--ink);
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.tab[aria-selected="true"] .count { color: var(--bg); }
|
||||
|
||||
/* ------------------------------------------------------------------- Zustaende */
|
||||
|
||||
.empty {
|
||||
border: 1px dashed var(--rule);
|
||||
background: var(--fill);
|
||||
padding: 30px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty .label { color: var(--ink-2); }
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
background: rgba(0, 0, 0, 0.86);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--pad);
|
||||
}
|
||||
|
||||
.overlay[hidden] { display: none; }
|
||||
.overlay .block { background: var(--bg); width: 100%; max-width: 380px; }
|
||||
|
||||
.overlay .block {
|
||||
background: var(--bg);
|
||||
border: 3px solid var(--ink);
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 26px;
|
||||
@@ -347,7 +476,7 @@ main { flex: 1; display: flex; flex-direction: column; }
|
||||
z-index: 70;
|
||||
max-width: 90vw;
|
||||
padding: 12px 16px;
|
||||
background: var(--fg);
|
||||
background: var(--ink);
|
||||
color: var(--bg);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -355,10 +484,8 @@ main { flex: 1; display: flex; flex-direction: column; }
|
||||
|
||||
.toast[hidden] { display: none; }
|
||||
|
||||
.hint { font-size: 12px; color: var(--muted); }
|
||||
|
||||
@media (min-width: 600px) {
|
||||
:root { --pad: 24px; }
|
||||
.app { border: 1px solid var(--fg); margin: 24px auto; min-height: calc(100vh - 48px); }
|
||||
.d-xl { font-size: 48px; }
|
||||
:root { --pad: 26px; }
|
||||
.app { border: 1px solid var(--ink); margin: 26px auto; min-height: calc(100vh - 52px); }
|
||||
.d-xl { font-size: 52px; }
|
||||
}
|
||||
|
||||
35
tests/test_extract.py
Normal file
35
tests/test_extract.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Der Parametersatz haengt am Modell - das war ein echter Produktionsfehler:
|
||||
claude-haiku-4-5 lehnt output_config.effort mit einem 400 ab.
|
||||
"""
|
||||
from app import extract
|
||||
|
||||
|
||||
def test_effort_nur_fuer_modelle_die_es_koennen():
|
||||
assert "claude-haiku-4-5".startswith(extract._NO_EFFORT)
|
||||
assert "claude-sonnet-4-5".startswith(extract._NO_EFFORT)
|
||||
assert not "claude-opus-5".startswith(extract._NO_EFFORT)
|
||||
assert not "claude-sonnet-5".startswith(extract._NO_EFFORT)
|
||||
|
||||
|
||||
def test_server_seitiger_fallback_nur_fuer_die_grossen():
|
||||
assert "claude-opus-5".startswith(extract._WITH_FALLBACK)
|
||||
assert "claude-fable-5-1".startswith(extract._WITH_FALLBACK)
|
||||
assert not "claude-haiku-4-5".startswith(extract._WITH_FALLBACK)
|
||||
assert not "claude-sonnet-5".startswith(extract._WITH_FALLBACK)
|
||||
|
||||
|
||||
def test_output_config_ohne_effort_bleibt_gueltig():
|
||||
ohne = extract._output_config(with_effort=False)
|
||||
mit = extract._output_config(with_effort=True)
|
||||
|
||||
assert "effort" not in ohne
|
||||
assert ohne["format"]["type"] == "json_schema"
|
||||
assert mit["effort"] == "low"
|
||||
assert mit["format"] == ohne["format"]
|
||||
|
||||
|
||||
def test_schema_verlangt_alle_felder_und_verbietet_zusaetze():
|
||||
assert extract.SCHEMA["additionalProperties"] is False
|
||||
assert set(extract.SCHEMA["required"]) == {"is_business_card", *extract.FIELDS}
|
||||
for field in extract.FIELDS:
|
||||
assert extract.SCHEMA["properties"][field]["type"] == ["string", "null"]
|
||||
@@ -17,7 +17,16 @@ def test_zuschnitte_haben_kartenformat():
|
||||
|
||||
for crop in crops:
|
||||
height, width = crop.shape[:2]
|
||||
assert (width, height) == (segment.OUT_WIDTH, segment.OUT_HEIGHT)
|
||||
assert abs(width / height - segment.CARD_RATIO) < 0.01
|
||||
assert segment.MIN_WIDTH <= width <= segment.OUT_WIDTH
|
||||
|
||||
|
||||
def test_zuschnitt_wird_nicht_hochskaliert():
|
||||
# Auf einem Stapelfoto ist eine Karte schmaler als OUT_WIDTH - der
|
||||
# Zuschnitt darf die Vorlage nicht kuenstlich aufblasen.
|
||||
crops, _ = segment.segment(photo_with_cards(count=12))
|
||||
|
||||
assert all(crop.shape[1] < segment.OUT_WIDTH for crop in crops)
|
||||
|
||||
|
||||
def test_zuschnitt_enthaelt_die_karte_und_nicht_den_tisch():
|
||||
|
||||
Reference in New Issue
Block a user