1 version

This commit is contained in:
2026-07-12 19:59:15 +02:00
parent f1165b5404
commit 88c49e1b54
39 changed files with 9328 additions and 52 deletions

177
src/services/analyse.ts Normal file
View File

@@ -0,0 +1,177 @@
/**
* Brief-Analyse: Bild oder PDF -> strukturiertes JSON (BriefAnalyse).
* Ein einziger Claude-Vision-Aufruf extrahiert alles: Erklärung in einfacher
* Sprache, Fachbegriffe, Fristen, Termine, Checkliste und Antwort-Optionen.
*/
import { BriefAnalyse } from '../types';
import { claudeJsonAufruf, ContentBlock } from './claudeClient';
/**
* JSON-Schema für die Analyse. Die Claude API erzwingt dieses Schema
* (Structured Output) — die Antwort ist damit garantiert valides JSON
* in exakt dieser Form. Optionale Objekte (frist/termin) sind über
* anyOf [object, null] modelliert, da Structured Output
* additionalProperties: false und vollständige required-Listen verlangt.
*/
const ANALYSE_SCHEMA = {
type: 'object',
additionalProperties: false,
required: [
'brieftyp',
'absender',
'kernaussage',
'erklaerung_einfach',
'fachbegriffe',
'frist',
'termin',
'checkliste',
'antwort_noetig',
'antwort_optionen',
],
properties: {
brieftyp: {
type: 'string',
description:
'Kurze Kategorie des Briefs, z. B. "Einladung Jobcenter", "Bescheid Arbeitsagentur", "Steuerbescheid".',
},
absender: {
type: 'string',
description: 'Die Behörde, die den Brief geschickt hat.',
},
kernaussage: {
type: 'string',
description:
'Antwort auf "Was will das Amt von mir?" in 2-3 kurzen Sätzen, Sprachniveau A2.',
},
erklaerung_einfach: {
type: 'string',
description:
'Ausführlichere Erklärung des gesamten Briefs in einfacher Alltagssprache (A2/B1-Niveau). Kurze Sätze. Keine Fachbegriffe ohne Erklärung.',
},
fachbegriffe: {
type: 'array',
description: 'Alle Fachbegriffe/Amtsdeutsch-Wörter aus dem Brief mit einfacher Erklärung.',
items: {
type: 'object',
additionalProperties: false,
required: ['begriff', 'erklaerung'],
properties: {
begriff: { type: 'string' },
erklaerung: { type: 'string', description: 'Erklärung in einfacher Sprache (A2).' },
},
},
},
frist: {
description: 'Frist, bis wann der Nutzer reagieren muss. null wenn keine Frist im Brief steht.',
anyOf: [
{
type: 'object',
additionalProperties: false,
required: ['datum', 'aktion'],
properties: {
datum: { type: 'string', description: 'ISO-Format JJJJ-MM-TT.' },
aktion: { type: 'string', description: 'Was bis dahin passieren muss, z. B. "Unterlagen einreichen".' },
},
},
{ type: 'null' },
],
},
termin: {
description: 'Persönlicher Termin (Vorsprache o. Ä.). null wenn kein Termin im Brief steht.',
anyOf: [
{
type: 'object',
additionalProperties: false,
required: ['datum', 'uhrzeit', 'ort'],
properties: {
datum: { type: 'string', description: 'ISO-Format JJJJ-MM-TT.' },
uhrzeit: {
anyOf: [{ type: 'string' }, { type: 'null' }],
description: 'Format HH:MM, null wenn keine Uhrzeit genannt.',
},
ort: {
anyOf: [{ type: 'string' }, { type: 'null' }],
description: 'Adresse/Raum, null wenn nicht genannt.',
},
},
},
{ type: 'null' },
],
},
checkliste: {
type: 'array',
description:
'To-do-Liste für den Nutzer: was tun, was mitbringen, was einreichen. Leer wenn nichts zu tun ist.',
items: { type: 'string' },
},
antwort_noetig: {
type: 'boolean',
description: 'true wenn der Nutzer antworten/reagieren muss.',
},
antwort_optionen: {
type: 'array',
description: 'Welche Antwort-Vorlagen für diesen Brief sinnvoll sind.',
items: {
type: 'string',
enum: [
'terminbestaetigung',
'terminverschiebung',
'widerspruch',
'unterlagen_nachreichen',
'rueckfrage',
],
},
},
},
} as const;
const SYSTEM_PROMPT = `Du bist ein Assistent, der deutschen Behördenbriefe für Privatpersonen verständlich macht. Die Nutzer sind Deutsche, die Amtsdeutsch schwer verstehen, oder Menschen mit Deutsch als Fremdsprache.
Deine Aufgabe:
1. Lies den fotografierten/hochgeladenen Brief vollständig.
2. Erkläre ihn in einfacher Alltagssprache (Sprachniveau A2/B1): kurze Sätze, keine Schachtelsätze, keine unerklärten Fachbegriffe.
3. Extrahiere Fristen und Termine exakt. Datumsangaben immer als ISO-Format (JJJJ-MM-TT). Wenn du ein Datum nicht sicher lesen kannst, lass das Feld null — erfinde niemals Daten.
4. Erstelle eine konkrete Checkliste, was der Nutzer tun muss.
5. Sei sachlich und beruhigend, nicht alarmierend.
Wichtig: Wenn das Bild kein Behördenbrief ist oder unlesbar ist, schreibe das klar in kernaussage und erklaerung_einfach und lasse frist/termin null.`;
/**
* Analysiert ein Brief-Foto (JPEG/PNG als base64) oder ein PDF.
*
* @param base64 - Dateiinhalt als Base64-String (ohne data:-Präfix)
* @param mimeType - z. B. 'image/jpeg', 'image/png' oder 'application/pdf'
*/
export async function analysiereBrief(
base64: string,
mimeType: string
): Promise<BriefAnalyse> {
// PDF nutzt einen document-Block, Bilder einen image-Block
const dateiBlock: ContentBlock =
mimeType === 'application/pdf'
? {
type: 'document',
source: { type: 'base64', media_type: 'application/pdf', data: base64 },
}
: {
type: 'image',
source: { type: 'base64', media_type: mimeType, data: base64 },
};
return claudeJsonAufruf<BriefAnalyse>({
system: SYSTEM_PROMPT,
schema: ANALYSE_SCHEMA,
messages: [
{
role: 'user',
content: [
dateiBlock,
{
type: 'text',
text: `Analysiere diesen Behördenbrief. Heute ist der ${new Date().toISOString().slice(0, 10)} (wichtig für relative Datumsangaben wie "innerhalb von 14 Tagen").`,
},
],
},
],
});
}

70
src/services/antwort.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* Antwort-Generator: erzeugt einen formellen deutschen Antwort-Entwurf
* passend zum Brieftyp und zur gewählten Vorlage. Der Nutzer kann den
* Entwurf danach frei bearbeiten und als PDF exportieren.
*/
import { AntwortTyp, ANTWORT_TYP_LABEL, BriefAnalyse } from '../types';
import { claudeJsonAufruf } from './claudeClient';
const ANTWORT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['betreff', 'text'],
properties: {
betreff: { type: 'string', description: 'Betreffzeile des Antwortbriefs.' },
text: {
type: 'string',
description:
'Vollständiger Brieftext inkl. Anrede und Grußformel. Platzhalter in eckigen Klammern für persönliche Daten, z. B. [Ihr Name], [Ihre Adresse], [Kundennummer].',
},
},
} as const;
export interface AntwortEntwurf {
betreff: string;
text: string;
}
/**
* @param analyse - Analyse des Original-Briefs (liefert Kontext)
* @param typ - gewählte Antwort-Vorlage
* @param hinweise - optionale Angaben des Nutzers (z. B. Wunschtermin, Grund)
*/
export async function generiereAntwort(
analyse: BriefAnalyse,
typ: AntwortTyp,
hinweise: string
): Promise<AntwortEntwurf> {
return claudeJsonAufruf<AntwortEntwurf>({
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.
- Persönliche Daten, die du nicht kennst, als Platzhalter in eckigen Klammern: [Ihr Name], [Ihre Adresse], [Aktenzeichen] usw.
- Aktenzeichen/Kundennummer aus dem Original-Brief referenzieren, falls bekannt.
- Bei Widersprüchen: sachlich, Frist wahren, Begründung ankündigen falls der Nutzer keine angegeben hat. Weise am Ende des Textes in einer Zeile "[Hinweis: ...]" darauf hin, dass bei rechtlichen Fragen eine Beratungsstelle oder ein Anwalt helfen kann — die App ersetzt keine Rechtsberatung.`,
schema: ANTWORT_SCHEMA,
messages: [
{
role: 'user',
content: `Schreibe eine Antwort auf diesen Behördenbrief.
Art der Antwort: ${ANTWORT_TYP_LABEL[typ]}
Analyse des Original-Briefs:
${JSON.stringify(
{
brieftyp: analyse.brieftyp,
absender: analyse.absender,
kernaussage: analyse.kernaussage,
frist: analyse.frist,
termin: analyse.termin,
},
null,
2
)}
Zusätzliche Angaben des Nutzers: ${hinweise.trim() || 'keine'}`,
},
],
});
}

View 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;
}

View File

@@ -0,0 +1,105 @@
/**
* Lokale Erinnerungen vor ablaufenden Fristen und Terminen.
* Nutzt lokal geplante Benachrichtigungen (expo-notifications) —
* funktioniert ohne Push-Server. Hinweis: In Expo Go auf Android sind
* Benachrichtigungen eingeschränkt; im Development Build voll verfügbar.
*/
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { BriefEintrag } from '../types';
/** Muss einmalig beim App-Start laufen (siehe App.tsx). */
export function initialisiereBenachrichtigungen(): void {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
}
async function stelleKanalUndRechteSicher(): Promise<boolean> {
try {
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('fristen', {
name: 'Fristen und Termine',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
});
}
const { granted } = await Notifications.requestPermissionsAsync();
return granted;
} catch {
return false;
}
}
/** Plant eine Benachrichtigung um 9:00 Uhr am angegebenen Tag (falls in der Zukunft). */
async function planeUm9Uhr(datumIso: string, titel: string, text: string): Promise<void> {
const datum = new Date(`${datumIso}T09:00:00`);
if (isNaN(datum.getTime()) || datum.getTime() <= Date.now()) return;
await Notifications.scheduleNotificationAsync({
content: { title: titel, body: text, sound: true },
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: datum,
channelId: 'fristen',
},
});
}
/** Verschiebt ein ISO-Datum um n Tage. */
function tageVorher(datumIso: string, tage: number): string {
const d = new Date(`${datumIso}T00:00:00`);
d.setDate(d.getDate() - tage);
return d.toISOString().slice(0, 10);
}
/**
* Plant Erinnerungen für einen Brief: je 3 Tage und 1 Tag vor Frist/Termin.
* Vergangene Zeitpunkte werden übersprungen. Fehler (z. B. verweigerte
* Berechtigung) brechen die App nicht — Erinnerungen sind ein Bonus.
*
* @returns true wenn mindestens eine Erinnerung geplant wurde
*/
export async function planeErinnerungen(brief: BriefEintrag): Promise<boolean> {
const ok = await stelleKanalUndRechteSicher();
if (!ok) return false;
let geplant = false;
try {
const { frist, termin, brieftyp } = brief.analyse;
if (frist) {
for (const abstand of [3, 1]) {
await planeUm9Uhr(
tageVorher(frist.datum, abstand),
`Frist in ${abstand} Tag${abstand > 1 ? 'en' : ''}: ${brieftyp}`,
`Bis ${formatiereDatum(frist.datum)}: ${frist.aktion}`
);
geplant = true;
}
}
if (termin) {
for (const abstand of [3, 1]) {
await planeUm9Uhr(
tageVorher(termin.datum, abstand),
`Termin in ${abstand} Tag${abstand > 1 ? 'en' : ''}: ${brieftyp}`,
`Am ${formatiereDatum(termin.datum)}${termin.uhrzeit ? ` um ${termin.uhrzeit} Uhr` : ''}${termin.ort ? `${termin.ort}` : ''}`
);
geplant = true;
}
}
} catch {
return geplant;
}
return geplant;
}
/** ISO-Datum -> deutsches Format TT.MM.JJJJ. */
export function formatiereDatum(iso: string): string {
const [j, m, t] = iso.split('-');
if (!j || !m || !t) return iso;
return `${t}.${m}.${j}`;
}

52
src/services/kalender.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* Termin-Export in den Geräte-Kalender (expo-calendar).
*/
import * as Calendar from 'expo-calendar';
import { Platform } from 'react-native';
import { Termin } from '../types';
async function holeKalenderId(): Promise<string | null> {
const { granted } = await Calendar.requestCalendarPermissionsAsync();
if (!granted) return null;
if (Platform.OS === 'ios') {
const standard = await Calendar.getDefaultCalendarAsync();
return standard?.id ?? null;
}
// Android: ersten beschreibbaren Kalender nehmen
const kalender = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT);
const beschreibbar = kalender.find((k) => k.allowsModifications);
return beschreibbar?.id ?? null;
}
/**
* Legt den Behörden-Termin im Kalender an.
* @returns true bei Erfolg, false wenn Berechtigung fehlt / kein Kalender.
*/
export async function terminZumKalender(
termin: Termin,
titel: string
): Promise<boolean> {
try {
const kalenderId = await holeKalenderId();
if (!kalenderId) return false;
// Startzeit: genannte Uhrzeit oder 09:00 als Standard
const uhrzeit = termin.uhrzeit ?? '09:00';
const start = new Date(`${termin.datum}T${uhrzeit}:00`);
if (isNaN(start.getTime())) return false;
const ende = new Date(start.getTime() + 60 * 60 * 1000); // 1 Stunde
await Calendar.createEventAsync(kalenderId, {
title: titel,
startDate: start,
endDate: ende,
location: termin.ort ?? undefined,
notes: 'Erstellt mit BehördenKlar',
alarms: [{ relativeOffset: -60 * 24 }], // Erinnerung 1 Tag vorher
});
return true;
} catch {
return false;
}
}

64
src/services/storage.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Lokale Speicherung.
* - API-Key: expo-secure-store (verschlüsselt, nie im Klartext auf der Platte)
* - Brief-Archiv + Einwilligung: AsyncStorage (bleibt auf dem Gerät)
*
* Datenschutz: Alle Daten bleiben lokal. Nichts wird an Server gesendet,
* außer dem Briefinhalt zur KI-Analyse (nach expliziter Einwilligung).
* loescheAlleDaten() entfernt restlos alles.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
import { BriefEintrag } from '../types';
const KEY_API = 'anthropic_api_key';
const KEY_BRIEFE = 'behoerdenklar_briefe';
const KEY_CONSENT = 'behoerdenklar_consent';
// ---- API-Key (sensibel -> SecureStore) ----
export async function holeApiKey(): Promise<string | null> {
return SecureStore.getItemAsync(KEY_API);
}
export async function speichereApiKey(key: string): Promise<void> {
if (key.trim()) {
await SecureStore.setItemAsync(KEY_API, key.trim());
} else {
await SecureStore.deleteItemAsync(KEY_API);
}
}
// ---- Einwilligung (Datenschutz-Consent) ----
export async function holeConsent(): Promise<boolean> {
return (await AsyncStorage.getItem(KEY_CONSENT)) === 'ja';
}
export async function speichereConsent(): Promise<void> {
await AsyncStorage.setItem(KEY_CONSENT, 'ja');
}
// ---- Brief-Archiv ----
export async function ladeBriefe(): Promise<BriefEintrag[]> {
const roh = await AsyncStorage.getItem(KEY_BRIEFE);
if (!roh) return [];
try {
return JSON.parse(roh) as BriefEintrag[];
} catch {
// Korrupte Daten nicht crashen lassen — leeres Archiv liefern
return [];
}
}
export async function speichereBriefe(briefe: BriefEintrag[]): Promise<void> {
await AsyncStorage.setItem(KEY_BRIEFE, JSON.stringify(briefe));
}
// ---- Alles löschen (Datenschutz-Anforderung) ----
export async function loescheAlleDaten(): Promise<void> {
await AsyncStorage.multiRemove([KEY_BRIEFE, KEY_CONSENT]);
await SecureStore.deleteItemAsync(KEY_API);
}

View File

@@ -0,0 +1,57 @@
/**
* Übersetzung der Analyse in eine Zielsprache — on demand, eine Sprache
* pro Aufruf (spart Tokens). Ergebnisse werden pro Brief+Sprache im
* Archiv gecacht (siehe store), damit kein Aufruf doppelt läuft.
*/
import { BriefAnalyse, Sprache, Uebersetzung } from '../types';
import { claudeJsonAufruf } from './claudeClient';
const UEBERSETZUNG_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['kernaussage', 'erklaerung_einfach', 'checkliste', 'fachbegriffe'],
properties: {
kernaussage: { type: 'string' },
erklaerung_einfach: { type: 'string' },
checkliste: { type: 'array', items: { type: 'string' } },
fachbegriffe: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['begriff', 'erklaerung'],
properties: {
// Der deutsche Begriff bleibt stehen (steht so im Brief!),
// nur die Erklärung wird übersetzt.
begriff: { type: 'string' },
erklaerung: { type: 'string' },
},
},
},
},
} as const;
export async function uebersetzeAnalyse(
analyse: BriefAnalyse,
sprache: Sprache
): Promise<Uebersetzung> {
return claudeJsonAufruf<Uebersetzung>({
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: [
{
role: 'user',
content: `Übersetze die folgenden Felder nach ${sprache.name} (${sprache.code}):\n\n${JSON.stringify(
{
kernaussage: analyse.kernaussage,
erklaerung_einfach: analyse.erklaerung_einfach,
checkliste: analyse.checkliste,
fachbegriffe: analyse.fachbegriffe,
},
null,
2
)}`,
},
],
});
}