generated from knso/webklar-preview-template
1 version
This commit is contained in:
177
src/services/analyse.ts
Normal file
177
src/services/analyse.ts
Normal 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
70
src/services/antwort.ts
Normal 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'}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
105
src/services/erinnerungen.ts
Normal file
105
src/services/erinnerungen.ts
Normal 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
52
src/services/kalender.ts
Normal 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
64
src/services/storage.ts
Normal 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);
|
||||
}
|
||||
57
src/services/uebersetzung.ts
Normal file
57
src/services/uebersetzung.ts
Normal 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
|
||||
)}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user