effort-Parameter modellabhängig senden, Design-Hierarchie, iOS-Datumsfeld
All checks were successful
Build & Deploy / deploy (push) Successful in 1m14s
All checks were successful
Build & Deploy / deploy (push) Successful in 1m14s
Fehler: claude-haiku-4-5 lehnt output_config.effort mit einem 400 ab, jede Karte lief in einen Lesefehler. effort und der server-seitige Fallback werden jetzt nur an Modelle geschickt, die sie annehmen; ein unbekanntes Modell mit engerem Parametersatz wird einmal ohne Zusatzparameter wiederholt, statt die Karte zu verlieren. Tests decken die Zuordnung ab. Design: Grau kommt als Hierarchiestufe dazu. Drei Ebenen für Fläche (weiß / grau gefüllt / schwarz), Text (schwarz / --ink-2 / --ink-3) und Linie (3px / 1px schwarz / graue Haarlinie). Vorher trug alles dieselbe 3px-Kante und dasselbe Schwarz, dadurch war keine Ordnung erkennbar. Konkret: nicht gewählte Filter treten zurück, Listenzeilen bekommen drei Textstufen und Statuspillen statt einer gleichförmigen Zeile, das Kopfband über Listen ist grau hinterlegt, zweitrangige und zerstörende Aktionen sind leiser als die primäre. iOS: Datumsfelder zentrieren ihren Wert und sacken in der Höhe ein. Die WebKit-Pseudoelemente ziehen sie auf die Form der übrigen Felder. Dazu die Domain in der Dokumentation auf scanner.lucas-orth.de. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user