generated from knso/webklar-preview-template
Release-Vorbereitung: Proxy, Kostenoptimierung, Freemium, Store-Assets
- Backend-Proxy (Cloudflare Worker in proxy/): API-Key serverseitig, Modell-Whitelist, max_tokens-Deckel, Tageslimits pro Gerät und IP - Modell-Split: Analyse auf Sonnet 5, Übersetzung/Antwort auf Haiku 4.5 - Bild-Downscaling auf 2000px vor dem Upload (ScanScreen) - Expo SDK 56 -> 57 Upgrade - Freemium: 3 Gratis-Analysen mit lokalem Zähler und Paywall-Hinweis - Einstellungen: API-Key-Feld nur noch im Dev-Modus (NUTZT_PROXY) - Build-Setup: Bundle-IDs, eas.json, Splash-Screen-Plugin - Tests: jest-expo + 17 Tests für die Ampel-/Fristlogik - Rechtliches: Datenschutzerklärung (Entwurf), Webseite (webseite/, live auf behoerdenklar.pages.dev), Store-Texte (store/) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
* Sprache, Fachbegriffe, Fristen, Termine, Checkliste und Antwort-Optionen.
|
||||
*/
|
||||
import { BriefAnalyse } from '../types';
|
||||
import { claudeJsonAufruf, ContentBlock } from './claudeClient';
|
||||
import { claudeJsonAufruf, ContentBlock, MODELL_ANALYSE } from './claudeClient';
|
||||
|
||||
/**
|
||||
* JSON-Schema für die Analyse. Die Claude API erzwingt dieses Schema
|
||||
@@ -159,6 +159,7 @@ export async function analysiereBrief(
|
||||
};
|
||||
|
||||
return claudeJsonAufruf<BriefAnalyse>({
|
||||
modell: MODELL_ANALYSE,
|
||||
system: SYSTEM_PROMPT,
|
||||
schema: ANALYSE_SCHEMA,
|
||||
messages: [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Entwurf danach frei bearbeiten und als PDF exportieren.
|
||||
*/
|
||||
import { AntwortTyp, ANTWORT_TYP_LABEL, BriefAnalyse } from '../types';
|
||||
import { claudeJsonAufruf } from './claudeClient';
|
||||
import { claudeJsonAufruf, MODELL_EINFACH } from './claudeClient';
|
||||
|
||||
const ANTWORT_SCHEMA = {
|
||||
type: 'object',
|
||||
@@ -36,6 +36,8 @@ export async function generiereAntwort(
|
||||
hinweise: string
|
||||
): Promise<AntwortEntwurf> {
|
||||
return claudeJsonAufruf<AntwortEntwurf>({
|
||||
modell: MODELL_EINFACH,
|
||||
maxTokens: 4000,
|
||||
system: `Du schreibst formelle, höfliche Antwortbriefe an deutsche Behörden im Namen von Privatpersonen. Regeln:
|
||||
- Korrektes, formelles Deutsch (der Empfänger ist eine Behörde — hier KEINE einfache Sprache).
|
||||
- Kurz und präzise, keine Floskeln über das Nötige hinaus.
|
||||
|
||||
@@ -13,16 +13,32 @@
|
||||
* deshalb hier bewusst rohes fetch gegen die Messages API.
|
||||
*/
|
||||
import * as Network from 'expo-network';
|
||||
import { holeApiKey } from './storage';
|
||||
import { holeApiKey, holeGeraeteId } from './storage';
|
||||
|
||||
/** Für Produktion hier die eigene Server-URL eintragen, z. B. 'https://api.meine-app.de/claude'. */
|
||||
const PROXY_URL: string | null = null;
|
||||
/**
|
||||
* Für Produktion hier die URL des deployten Workers eintragen,
|
||||
* z. B. 'https://behoerdenklar-proxy.<account>.workers.dev'.
|
||||
* Setup-Anleitung: proxy/README.md
|
||||
*/
|
||||
const PROXY_URL: string | null =
|
||||
'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
|
||||
|
||||
/** true = Produktionsmodus über den Backend-Proxy (kein API-Key in der App nötig). */
|
||||
export const NUTZT_PROXY = PROXY_URL !== null;
|
||||
|
||||
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
||||
const ANTHROPIC_VERSION = '2023-06-01';
|
||||
|
||||
/** Standard-Modell für alle Aufrufe. */
|
||||
export const MODELL = 'claude-opus-4-8';
|
||||
/**
|
||||
* Modell-Split zur Kostenoptimierung:
|
||||
* - Brief-Analyse (Vision, Genauigkeit wichtig): Sonnet 5 (~2,5 Cent/Brief)
|
||||
* - Übersetzung & Antwort-Entwürfe (einfache Textaufgaben): Haiku 4.5 (~halber Preis)
|
||||
*
|
||||
* Hinweis: Der Backend-Proxy erlaubt nur diese Modelle (Whitelist in
|
||||
* proxy/src/index.ts) — bei Änderungen dort mitziehen.
|
||||
*/
|
||||
export const MODELL_ANALYSE = 'claude-sonnet-5';
|
||||
export const MODELL_EINFACH = 'claude-haiku-4-5';
|
||||
|
||||
// ---- Typen für den Request-Body (Teilmenge der Messages API) ----
|
||||
|
||||
@@ -44,6 +60,8 @@ export interface DocumentBlock {
|
||||
export type ContentBlock = TextBlock | ImageBlock | DocumentBlock;
|
||||
|
||||
export interface ClaudeRequest {
|
||||
/** Welches Modell: MODELL_ANALYSE (Vision) oder MODELL_EINFACH (Text). */
|
||||
modell: string;
|
||||
system?: string;
|
||||
messages: { role: 'user' | 'assistant'; content: string | ContentBlock[] }[];
|
||||
/** JSON-Schema für Structured Output — garantiert valides JSON in der Antwort. */
|
||||
@@ -75,10 +93,11 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
|
||||
|
||||
// 2. Request-Body bauen
|
||||
const body: Record<string, unknown> = {
|
||||
model: MODELL,
|
||||
model: anfrage.modell,
|
||||
max_tokens: anfrage.maxTokens ?? 16000,
|
||||
// Adaptives Denken verbessert die Extraktions-Genauigkeit deutlich
|
||||
thinking: { type: 'adaptive' },
|
||||
// Kein explizites thinking-Feld: Sonnet 5 denkt adaptiv von selbst
|
||||
// (gut für die Extraktions-Genauigkeit), Haiku 4.5 unterstützt den
|
||||
// adaptiven Modus nicht und würde die Anfrage ablehnen.
|
||||
messages: anfrage.messages,
|
||||
};
|
||||
if (anfrage.system) body.system = anfrage.system;
|
||||
@@ -96,6 +115,8 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
|
||||
};
|
||||
if (PROXY_URL) {
|
||||
url = PROXY_URL; // Der Proxy hält den API-Key serverseitig
|
||||
// Anonyme ID, damit der Proxy das Tageslimit pro Gerät durchsetzen kann
|
||||
headers['x-geraete-id'] = await holeGeraeteId();
|
||||
} else {
|
||||
const apiKey = await holeApiKey();
|
||||
if (!apiKey) {
|
||||
@@ -137,8 +158,11 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
|
||||
technisch
|
||||
);
|
||||
case 429:
|
||||
// Direkt-Modus: API-Rate-Limit. Proxy-Modus: Tageslimit pro Gerät.
|
||||
throw new ClaudeFehler(
|
||||
'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.',
|
||||
PROXY_URL
|
||||
? 'Das tägliche Kontingent ist aufgebraucht. Bitte versuchen Sie es morgen erneut.'
|
||||
: 'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.',
|
||||
technisch
|
||||
);
|
||||
case 413:
|
||||
|
||||
@@ -14,6 +14,16 @@ import { BriefEintrag } from '../types';
|
||||
const KEY_API = 'anthropic_api_key';
|
||||
const KEY_BRIEFE = 'behoerdenklar_briefe';
|
||||
const KEY_CONSENT = 'behoerdenklar_consent';
|
||||
const KEY_GERAETE_ID = 'behoerdenklar_geraete_id';
|
||||
const KEY_SCAN_ANZAHL = 'behoerdenklar_scan_anzahl';
|
||||
|
||||
/**
|
||||
* Freemium: so viele Brief-Analysen sind kostenlos. Danach braucht es das
|
||||
* Abo (kommt mit dem IAP-Einbau). Der Zähler liegt lokal — das ist bewusst
|
||||
* "weich" (Neuinstallation setzt ihn zurück); die harte Grenze ist das
|
||||
* Tageslimit des Backend-Proxys. Echte Abo-Prüfung folgt serverseitig.
|
||||
*/
|
||||
export const GRATIS_ANALYSEN = 3;
|
||||
|
||||
// ---- API-Key (sensibel -> SecureStore) ----
|
||||
|
||||
@@ -29,6 +39,42 @@ export async function speichereApiKey(key: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Geräte-ID (anonym, nur fürs Tageslimit des Backend-Proxys) ----
|
||||
|
||||
/**
|
||||
* Liefert eine zufällige, anonyme Geräte-ID (wird beim ersten Aufruf erzeugt).
|
||||
* Sie enthält keine Gerätedaten und dient dem Proxy ausschließlich dazu,
|
||||
* das Anfrage-Tageslimit pro Installation durchzusetzen.
|
||||
*/
|
||||
export async function holeGeraeteId(): Promise<string> {
|
||||
let id = await AsyncStorage.getItem(KEY_GERAETE_ID);
|
||||
if (!id) {
|
||||
// Math.random reicht hier: die ID ist kein Geheimnis, nur ein Zähl-Schlüssel
|
||||
id =
|
||||
'g_' +
|
||||
Array.from({ length: 24 }, () =>
|
||||
Math.floor(Math.random() * 36).toString(36)
|
||||
).join('');
|
||||
await AsyncStorage.setItem(KEY_GERAETE_ID, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---- Freemium-Zähler ----
|
||||
|
||||
/** Wie viele Brief-Analysen wurden auf diesem Gerät schon durchgeführt? */
|
||||
export async function holeAnzahlAnalysen(): Promise<number> {
|
||||
const roh = await AsyncStorage.getItem(KEY_SCAN_ANZAHL);
|
||||
const anzahl = roh ? parseInt(roh, 10) : 0;
|
||||
return Number.isFinite(anzahl) ? anzahl : 0;
|
||||
}
|
||||
|
||||
/** Nach jeder erfolgreichen Analyse aufrufen. */
|
||||
export async function zaehleAnalyse(): Promise<void> {
|
||||
const bisher = await holeAnzahlAnalysen();
|
||||
await AsyncStorage.setItem(KEY_SCAN_ANZAHL, String(bisher + 1));
|
||||
}
|
||||
|
||||
// ---- Einwilligung (Datenschutz-Consent) ----
|
||||
|
||||
export async function holeConsent(): Promise<boolean> {
|
||||
@@ -59,6 +105,14 @@ export async function speichereBriefe(briefe: BriefEintrag[]): Promise<void> {
|
||||
// ---- Alles löschen (Datenschutz-Anforderung) ----
|
||||
|
||||
export async function loescheAlleDaten(): Promise<void> {
|
||||
await AsyncStorage.multiRemove([KEY_BRIEFE, KEY_CONSENT]);
|
||||
// "Alle Daten löschen" heißt wirklich alle — auch Zähler und Geräte-ID.
|
||||
// Dass damit das Gratis-Kontingent zurücksetzbar ist, nehmen wir in Kauf
|
||||
// (Neuinstallation könnte das ohnehin); der Proxy limitiert weiterhin.
|
||||
await AsyncStorage.multiRemove([
|
||||
KEY_BRIEFE,
|
||||
KEY_CONSENT,
|
||||
KEY_GERAETE_ID,
|
||||
KEY_SCAN_ANZAHL,
|
||||
]);
|
||||
await SecureStore.deleteItemAsync(KEY_API);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Archiv gecacht (siehe store), damit kein Aufruf doppelt läuft.
|
||||
*/
|
||||
import { BriefAnalyse, Sprache, Uebersetzung } from '../types';
|
||||
import { claudeJsonAufruf } from './claudeClient';
|
||||
import { claudeJsonAufruf, MODELL_EINFACH } from './claudeClient';
|
||||
|
||||
const UEBERSETZUNG_SCHEMA = {
|
||||
type: 'object',
|
||||
@@ -36,6 +36,8 @@ export async function uebersetzeAnalyse(
|
||||
sprache: Sprache
|
||||
): Promise<Uebersetzung> {
|
||||
return claudeJsonAufruf<Uebersetzung>({
|
||||
modell: MODELL_EINFACH,
|
||||
maxTokens: 6000,
|
||||
system: `Du übersetzt Erklärungen deutscher Behördenbriefe in andere Sprachen. Übersetze in einfache, klare Sprache (Niveau A2/B1 der Zielsprache). Deutsche Fachbegriffe (Feld "begriff") NICHT übersetzen — sie stehen so im Original-Brief und der Nutzer muss sie wiedererkennen. Nur die Erklärungen übersetzen.`,
|
||||
schema: UEBERSETZUNG_SCHEMA,
|
||||
messages: [
|
||||
|
||||
Reference in New Issue
Block a user