generated from knso/webklar-preview-template
1 version
This commit is contained in:
186
src/services/claudeClient.ts
Normal file
186
src/services/claudeClient.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Low-Level-Client für die Claude API (Anthropic).
|
||||
*
|
||||
* Zwei Betriebsmodi:
|
||||
* 1. DEV (Standard): Direkter Aufruf der Anthropic-API mit dem API-Key aus
|
||||
* expo-secure-store (Nutzer trägt ihn in den Einstellungen ein).
|
||||
* 2. PROD: PROXY_URL setzen — dann laufen alle Anfragen über den eigenen
|
||||
* Server, der den API-Key hält. Endkunden brauchen dann keinen Key.
|
||||
* Der Proxy muss denselben Request-Body akzeptieren und die
|
||||
* Anthropic-Antwort unverändert durchreichen.
|
||||
*
|
||||
* Hinweis: Das offizielle @anthropic-ai/sdk unterstützt React Native nicht,
|
||||
* deshalb hier bewusst rohes fetch gegen die Messages API.
|
||||
*/
|
||||
import * as Network from 'expo-network';
|
||||
import { holeApiKey } 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;
|
||||
|
||||
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';
|
||||
|
||||
// ---- Typen für den Request-Body (Teilmenge der Messages API) ----
|
||||
|
||||
export interface TextBlock {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ImageBlock {
|
||||
type: 'image';
|
||||
source: { type: 'base64'; media_type: string; data: string };
|
||||
}
|
||||
|
||||
export interface DocumentBlock {
|
||||
type: 'document';
|
||||
source: { type: 'base64'; media_type: 'application/pdf'; data: string };
|
||||
}
|
||||
|
||||
export type ContentBlock = TextBlock | ImageBlock | DocumentBlock;
|
||||
|
||||
export interface ClaudeRequest {
|
||||
system?: string;
|
||||
messages: { role: 'user' | 'assistant'; content: string | ContentBlock[] }[];
|
||||
/** JSON-Schema für Structured Output — garantiert valides JSON in der Antwort. */
|
||||
schema?: object;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
/** Fehler mit nutzerfreundlicher deutscher Meldung. */
|
||||
export class ClaudeFehler extends Error {
|
||||
constructor(meldung: string, public readonly technisch?: string) {
|
||||
super(meldung);
|
||||
this.name = 'ClaudeFehler';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt einen Claude-Aufruf aus und gibt das geparste JSON-Objekt zurück.
|
||||
* Nur für Aufrufe mit `schema` (Structured Output) gedacht — alle
|
||||
* BehördenKlar-Aufrufe nutzen Structured Output.
|
||||
*/
|
||||
export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
|
||||
// 1. Offline-Check mit klarer Meldung (Anforderung: Offline-Hinweis)
|
||||
const netz = await Network.getNetworkStateAsync();
|
||||
if (!netz.isConnected || netz.isInternetReachable === false) {
|
||||
throw new ClaudeFehler(
|
||||
'Keine Internetverbindung. Bitte verbinden Sie sich mit dem Internet und versuchen Sie es erneut.'
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Request-Body bauen
|
||||
const body: Record<string, unknown> = {
|
||||
model: MODELL,
|
||||
max_tokens: anfrage.maxTokens ?? 16000,
|
||||
// Adaptives Denken verbessert die Extraktions-Genauigkeit deutlich
|
||||
thinking: { type: 'adaptive' },
|
||||
messages: anfrage.messages,
|
||||
};
|
||||
if (anfrage.system) body.system = anfrage.system;
|
||||
if (anfrage.schema) {
|
||||
body.output_config = {
|
||||
format: { type: 'json_schema', schema: anfrage.schema },
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Endpunkt + Header je nach Modus
|
||||
let url = ANTHROPIC_URL;
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
'anthropic-version': ANTHROPIC_VERSION,
|
||||
};
|
||||
if (PROXY_URL) {
|
||||
url = PROXY_URL; // Der Proxy hält den API-Key serverseitig
|
||||
} else {
|
||||
const apiKey = await holeApiKey();
|
||||
if (!apiKey) {
|
||||
throw new ClaudeFehler(
|
||||
'Kein API-Schlüssel hinterlegt. Bitte tragen Sie Ihren Anthropic-API-Schlüssel in den Einstellungen ein.'
|
||||
);
|
||||
}
|
||||
headers['x-api-key'] = apiKey;
|
||||
}
|
||||
|
||||
// 4. Aufruf
|
||||
let antwort: Response;
|
||||
try {
|
||||
antwort = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new ClaudeFehler(
|
||||
'Verbindung fehlgeschlagen. Bitte prüfen Sie Ihre Internetverbindung.',
|
||||
String(e)
|
||||
);
|
||||
}
|
||||
|
||||
// 5. HTTP-Fehler in verständliche Meldungen übersetzen
|
||||
if (!antwort.ok) {
|
||||
let technisch = '';
|
||||
try {
|
||||
const fehlerJson = await antwort.json();
|
||||
technisch = fehlerJson?.error?.message ?? '';
|
||||
} catch {
|
||||
/* Fehler-Body war kein JSON */
|
||||
}
|
||||
switch (antwort.status) {
|
||||
case 401:
|
||||
throw new ClaudeFehler(
|
||||
'Der API-Schlüssel ist ungültig. Bitte in den Einstellungen prüfen.',
|
||||
technisch
|
||||
);
|
||||
case 429:
|
||||
throw new ClaudeFehler(
|
||||
'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.',
|
||||
technisch
|
||||
);
|
||||
case 413:
|
||||
throw new ClaudeFehler(
|
||||
'Das Bild ist zu groß. Bitte fotografieren Sie den Brief erneut.',
|
||||
technisch
|
||||
);
|
||||
default:
|
||||
if (antwort.status >= 500) {
|
||||
throw new ClaudeFehler(
|
||||
'Der KI-Dienst ist gerade nicht erreichbar. Bitte später erneut versuchen.',
|
||||
technisch
|
||||
);
|
||||
}
|
||||
throw new ClaudeFehler(
|
||||
'Die Analyse ist fehlgeschlagen. Bitte versuchen Sie es erneut.',
|
||||
`HTTP ${antwort.status}: ${technisch}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const daten = await antwort.json();
|
||||
|
||||
// 6. Stop-Reason prüfen (Sicherheits-Ablehnung / abgeschnittene Antwort)
|
||||
if (daten.stop_reason === 'refusal') {
|
||||
throw new ClaudeFehler(
|
||||
'Die KI konnte diesen Inhalt nicht verarbeiten. Bitte prüfen Sie, ob das Foto wirklich einen Behördenbrief zeigt.'
|
||||
);
|
||||
}
|
||||
if (daten.stop_reason === 'max_tokens') {
|
||||
throw new ClaudeFehler(
|
||||
'Der Brief ist sehr lang — die Analyse wurde abgeschnitten. Bitte fotografieren Sie ggf. nur die wichtigste Seite.'
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Structured Output: der letzte Text-Block enthält garantiert valides JSON
|
||||
// (davor können Thinking-Blöcke stehen — deshalb nicht content[0] nehmen)
|
||||
const textBlock = [...(daten.content ?? [])]
|
||||
.reverse()
|
||||
.find((b: { type: string }) => b.type === 'text');
|
||||
if (!textBlock) {
|
||||
throw new ClaudeFehler('Die KI hat keine verwertbare Antwort geliefert.');
|
||||
}
|
||||
return JSON.parse(textBlock.text) as T;
|
||||
}
|
||||
Reference in New Issue
Block a user