leafittome/functions/src/index.ts
cschlaefke 49b7f4496c App Check: Functions auf enforceAppCheck=true vorbereitet (noch nicht deployt)
Zweiter Schritt aus firebase-einrichtung.md Schritt 9 — alle sieben
onCall-Functions (identifyPlant, diagnosePlant, analyzeLocation,
assessPlantFit, joinHousehold, leaveHousehold, removeMember) lehnen
nach dem Deploy Aufrufe ohne gültiges App-Check-Token ab. Bewusst noch
nicht deployt: Chris muss vorher in der Firebase-Console bestätigen,
dass "Verified requests" für beide Apps ankommen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 12:30:46 +02:00

432 lines
15 KiB
TypeScript

import {onCall, HttpsError} from "firebase-functions/v2/https";
import {defineSecret} from "firebase-functions/params";
import * as admin from "firebase-admin";
admin.initializeApp();
export {sendDailyReminders} from "./reminders";
export {joinHousehold, leaveHousehold, removeMember} from "./household";
// Secrets liegen im Google Secret Manager (firebase functions:secrets:set),
// niemals im Code oder in der App.
const plantNetApiKey = defineSecret("PLANTNET_API_KEY");
const anthropicApiKey = defineSecret("ANTHROPIC_API_KEY");
const ANTHROPIC_MODEL = "claude-sonnet-5";
// Unterhalb dieser PlantNet-Konfidenz holen wir eine Zweitmeinung von Claude.
const PLANTNET_CONFIDENCE_THRESHOLD = 0.3;
interface PlantNetResult {
score: number;
scientificName: string;
commonNames: string[];
}
interface CareProfile {
germanName: string;
description: string;
careNotes: string;
wateringIntervalDays: number;
fertilizingIntervalDays: number;
}
/** Identifikation über die kostenlose PlantNet-API. */
async function identifyWithPlantNet(
imageBase64: string,
apiKey: string
): Promise<PlantNetResult | null> {
const form = new FormData();
const bytes = Buffer.from(imageBase64, "base64");
form.append("images", new Blob([bytes], {type: "image/jpeg"}), "plant.jpg");
form.append("organs", "auto");
const response = await fetch(
`https://my-api.plantnet.org/v2/identify/all?api-key=${apiKey}`,
{method: "POST", body: form}
);
if (!response.ok) {
console.error("PlantNet-Fehler", response.status, await response.text());
return null;
}
const json = (await response.json()) as {
results?: {
score: number;
species: {
scientificNameWithoutAuthor: string;
commonNames?: string[];
};
}[];
};
const best = json.results?.[0];
if (!best) return null;
return {
score: best.score,
scientificName: best.species.scientificNameWithoutAuthor,
commonNames: best.species.commonNames ?? [],
};
}
type ClaudeContent =
| {type: "text"; text: string}
| {type: "image"; source: {type: "base64"; media_type: string; data: string}};
/** Ruft die Claude-API mit fertigem Content-Array auf, erwartet reines JSON zurück. */
async function callClaude<T>(
content: ClaudeContent[],
apiKey: string
): Promise<T> {
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: ANTHROPIC_MODEL,
// Claude Sonnet 5 denkt standardmäßig adaptiv mit; das Limit deckt
// Denk- UND Antwort-Tokens ab — großzügig, damit nichts abreißt.
max_tokens: 2048,
messages: [{role: "user", content}],
}),
});
if (!response.ok) {
console.error("Anthropic-Fehler", response.status, await response.text());
throw new HttpsError("internal", "KI-Anfrage fehlgeschlagen.");
}
const json = (await response.json()) as {
content: {type: string; text?: string}[];
};
const text = json.content.find((c) => c.type === "text")?.text ?? "";
// Eventuelle Markdown-Zäune entfernen, dann JSON parsen.
const cleaned = text.replace(/```json|```/g, "").trim();
try {
return JSON.parse(cleaned) as T;
} catch {
console.error("Claude lieferte kein JSON:", text);
throw new HttpsError("internal", "KI-Antwort war nicht lesbar.");
}
}
/** Ein Aufruf der Claude-API mit Bild und Prompt, erwartet reines JSON zurück. */
function askClaude<T>(
imageBase64: string,
prompt: string,
apiKey: string
): Promise<T> {
return callClaude<T>(
[
{
type: "image",
source: {type: "base64", media_type: "image/jpeg", data: imageBase64},
},
{type: "text", text: prompt},
],
apiKey
);
}
/** Ein reiner Text-Aufruf der Claude-API (ohne Bild), erwartet reines JSON zurück. */
function askClaudeText<T>(prompt: string, apiKey: string): Promise<T> {
return callClaude<T>([{type: "text", text: prompt}], apiKey);
}
/**
* Pflanze per Foto erkennen und ein deutsches Pflegeprofil erstellen.
*
* Ablauf (Hybrid-Modell):
* 1. PlantNet (kostenlos) bestimmt die Art.
* 2. Bei zu geringer Konfidenz bestimmt Claude die Art als Fallback.
* 3. Claude erstellt Beschreibung, Pflegehinweise und Intervall-Vorschläge.
*/
interface DiagnosisResult {
healthy: boolean;
matchesSpecies: boolean;
summary: string;
details: string;
treatment: string;
prevention: string;
}
/**
* Krankheits-Diagnose per Foto (V3).
*
* Claude beurteilt den Gesundheitszustand der Pflanze auf dem Foto und
* liefert Befund, Ursache, Behandlung und Vorbeugung auf Deutsch.
* Optional kann die App die bekannte Art mitgeben (speciesHint).
*/
export const diagnosePlant = onCall(
{
region: "europe-west3",
enforceAppCheck: true,
secrets: [anthropicApiKey],
memory: "512MiB",
timeoutSeconds: 120,
maxInstances: 5,
},
async (request) => {
if (!request.auth) {
throw new HttpsError("unauthenticated", "Anmeldung erforderlich.");
}
const imageBase64 = request.data?.imageBase64;
if (typeof imageBase64 !== "string" || imageBase64.length === 0) {
throw new HttpsError("invalid-argument", "imageBase64 fehlt.");
}
if (imageBase64.length > 8_000_000) {
throw new HttpsError("invalid-argument", "Bild ist zu groß.");
}
const speciesHint =
typeof request.data?.speciesHint === "string" ?
request.data.speciesHint.slice(0, 200) :
"";
// Die Art ist nur eine UNBESTÄTIGTE Angabe des Besitzers — Claude soll
// selbst prüfen, ob das Foto wirklich diese Pflanze zeigt (der Nutzer
// kann versehentlich das Foto einer anderen Pflanze hochladen).
const speciesPart = speciesHint ?
`Laut Besitzer soll es sich um "${speciesHint}" handeln — verlasse ` +
"dich NICHT darauf, sondern prüfe selbst, welche Pflanze auf dem " +
"Foto zu sehen ist. Zeigt das Foto eindeutig eine andere Art, " +
"beurteile die Pflanze, die tatsächlich zu sehen ist, und nenne " +
"ihre Art im Kurzbefund. " :
"";
const diagnosis = await askClaude<DiagnosisResult>(
imageBase64,
"Du bist ein erfahrener Pflanzenarzt. Beurteile den Gesundheitszustand " +
`der Pflanze auf dem Foto. ${speciesPart}` +
"Achte auf Blattverfärbungen, Flecken, Schädlinge, Schimmel, " +
"Trockenheit, Überwässerung und ähnliche Anzeichen. " +
"Sprache: Deutsch, einfach und freundlich (auch für ältere Menschen " +
"gut verständlich, keine Fachbegriffe ohne Erklärung). Sprich die " +
"Person immer mit „du“ an, niemals mit „Sie“. Schreibe reinen " +
"Fließtext ohne HTML-Tags (kein <br> o. Ä.) und ohne " +
"Markdown-Formatierung. Antworte NUR mit JSON im Format " +
'{"healthy": <true wenn die Pflanze gesund wirkt, sonst false>, ' +
'"matchesSpecies": <false NUR wenn eine Art angegeben wurde und das ' +
"Foto eindeutig eine andere Pflanzenart zeigt, sonst true>, " +
'"summary": "<Kurzbefund in 1 Satz>", ' +
'"details": "<2-3 Sätze: was ist zu sehen und was ist die ' +
'wahrscheinliche Ursache>", ' +
'"treatment": "<2-4 Sätze mit konkreten Behandlungs-Schritten; bei ' +
'gesunder Pflanze kurze Pflegetipps>", ' +
'"prevention": "<1-2 Sätze, wie man dem Problem vorbeugt>"}. ' +
"Wenn auf dem Foto keine Pflanze erkennbar ist, setze summary auf " +
"einen leeren String.",
anthropicApiKey.value()
);
if (!diagnosis.summary) {
throw new HttpsError(
"not-found",
"Auf dem Foto wurde keine Pflanze erkannt."
);
}
return diagnosis;
}
);
export const identifyPlant = onCall(
{
region: "europe-west3",
enforceAppCheck: true,
secrets: [plantNetApiKey, anthropicApiKey],
memory: "512MiB",
timeoutSeconds: 120,
maxInstances: 5,
},
async (request) => {
if (!request.auth) {
throw new HttpsError("unauthenticated", "Anmeldung erforderlich.");
}
const imageBase64 = request.data?.imageBase64;
if (typeof imageBase64 !== "string" || imageBase64.length === 0) {
throw new HttpsError("invalid-argument", "imageBase64 fehlt.");
}
if (imageBase64.length > 8_000_000) {
throw new HttpsError("invalid-argument", "Bild ist zu groß.");
}
// 1) Arterkennung: PlantNet zuerst, Claude als Fallback.
let scientificName: string | null = null;
let confidence = 0;
let source = "plantnet";
const plantNet = await identifyWithPlantNet(
imageBase64,
plantNetApiKey.value()
).catch((error) => {
console.error("PlantNet nicht erreichbar:", error);
return null;
});
if (plantNet && plantNet.score >= PLANTNET_CONFIDENCE_THRESHOLD) {
scientificName = plantNet.scientificName;
confidence = plantNet.score;
} else {
source = "claude";
const claudeId = await askClaude<{scientificName: string; confidence: number}>(
imageBase64,
"Bestimme die Pflanze auf dem Foto. Antworte NUR mit JSON im Format " +
'{"scientificName": "<botanischer Name>", "confidence": <0..1>}. ' +
"Wenn keine Pflanze erkennbar ist, setze scientificName auf leeren String.",
anthropicApiKey.value()
);
scientificName = claudeId.scientificName || plantNet?.scientificName || null;
confidence = claudeId.scientificName
? claudeId.confidence
: plantNet?.score ?? 0;
}
if (!scientificName) {
throw new HttpsError(
"not-found",
"Auf dem Foto wurde keine Pflanze erkannt."
);
}
// 2) Pflegeprofil auf Deutsch, mit dem Foto als Kontext (Zustand, Topfgröße).
const profile = await askClaude<CareProfile>(
imageBase64,
`Die Pflanze auf dem Foto wurde als "${scientificName}" bestimmt. ` +
"Erstelle ein Pflegeprofil auf Deutsch, in einfacher, freundlicher Sprache " +
"(auch für ältere Menschen gut verständlich, keine Fachbegriffe ohne Erklärung). " +
"Antworte NUR mit JSON im Format " +
'{"germanName": "<gebräuchlicher deutscher Name>", ' +
'"description": "<2-3 Sätze über die Pflanze>", ' +
'"careNotes": "<2-3 Sätze konkrete Pflegetipps zu Gießen, Licht, Dünger>", ' +
'"wateringIntervalDays": <Zahl>, "fertilizingIntervalDays": <Zahl>}. ' +
"Die Intervalle sind Richtwerte für eine Zimmerpflanze in der aktuellen Jahreszeit.",
anthropicApiKey.value()
);
return {
scientificName,
confidence,
source,
germanName: profile.germanName,
description: profile.description,
careNotes: profile.careNotes,
wateringIntervalDays: profile.wateringIntervalDays,
fertilizingIntervalDays: profile.fertilizingIntervalDays,
};
}
);
/** Die vier Licht-Kategorien, auf die Claude sich bei der Standort-Analyse festlegt. */
const LIGHT_CATEGORIES = ["Volle Sonne", "Hell, indirekt", "Halbschatten", "Schatten"];
interface LocationAnalysis {
lightCategory: string;
description: string;
}
/**
* Standort-Analyse per Foto (V3 Baustein 3).
*
* Claude schätzt die Lichtverhältnisse an einem Stellplatz anhand eines
* Fotos ein. Ergebnis wird von der App auf dem Stellplatz-Dokument
* gespeichert (keine Historie nötig, immer der aktuelle Stand).
*/
export const analyzeLocation = onCall(
{
region: "europe-west3",
enforceAppCheck: true,
secrets: [anthropicApiKey],
memory: "512MiB",
timeoutSeconds: 120,
maxInstances: 5,
},
async (request) => {
if (!request.auth) {
throw new HttpsError("unauthenticated", "Anmeldung erforderlich.");
}
const imageBase64 = request.data?.imageBase64;
if (typeof imageBase64 !== "string" || imageBase64.length === 0) {
throw new HttpsError("invalid-argument", "imageBase64 fehlt.");
}
if (imageBase64.length > 8_000_000) {
throw new HttpsError("invalid-argument", "Bild ist zu groß.");
}
return askClaude<LocationAnalysis>(
imageBase64,
"Du bist Experte für die Lichtverhältnisse an Zimmerpflanzen-Stellplätzen. " +
"Beurteile anhand des Fotos, wie viel und welche Art von Licht an diesem " +
"Stellplatz herrscht (z. B. Fensternähe, direkte Sonneneinstrahlung, " +
"Verschattung durch Vorhänge/Möbel/andere Räume). " +
"Sprache: Deutsch, einfach und freundlich (auch für ältere Menschen gut " +
"verständlich, keine Fachbegriffe ohne Erklärung). Sprich die Person " +
"immer mit „du“ an, niemals mit „Sie“. Schreibe reinen Fließtext ohne " +
"HTML-Tags und ohne Markdown-Formatierung. Antworte NUR mit JSON im " +
`Format {"lightCategory": "<GENAU einer der folgenden Werte: ` +
`${LIGHT_CATEGORIES.map((c) => `"${c}"`).join(", ")}>", ` +
'"description": "<1-2 Sätze, was auf dem Foto zu den Lichtverhältnissen ' +
'zu erkennen ist>"}.',
anthropicApiKey.value()
);
}
);
interface PlantFitAssessment {
stars: number;
reasoning: string;
}
/**
* Eignungs-Bewertung einer Pflanze für einen (bereits analysierten) Stellplatz
* (V3 Baustein 3). Rein textbasiert — kein neues Foto nötig, da Pflanzenprofil
* und Standort-Analyse bereits als Text vorliegen. Wird ausschließlich auf
* Abruf durch den Nutzer ausgelöst (Kostenkontrolle).
*/
export const assessPlantFit = onCall(
{
region: "europe-west3",
enforceAppCheck: true,
secrets: [anthropicApiKey],
memory: "512MiB",
timeoutSeconds: 120,
maxInstances: 5,
},
async (request) => {
if (!request.auth) {
throw new HttpsError("unauthenticated", "Anmeldung erforderlich.");
}
const species = typeof request.data?.species === "string" ?
request.data.species.slice(0, 200) :
"";
const description = typeof request.data?.description === "string" ?
request.data.description.slice(0, 1000) :
"";
const careNotes = typeof request.data?.careNotes === "string" ?
request.data.careNotes.slice(0, 1000) :
"";
const lightCategory = typeof request.data?.lightCategory === "string" ?
request.data.lightCategory.slice(0, 200) :
"";
const lightAssessment = typeof request.data?.lightAssessment === "string" ?
request.data.lightAssessment.slice(0, 1000) :
"";
if (!species || !lightCategory) {
throw new HttpsError(
"invalid-argument",
"species und lightCategory sind erforderlich."
);
}
return askClaudeText<PlantFitAssessment>(
"Du bist Pflanzen-Experte. Beurteile, wie gut die folgende Pflanze an " +
"den beschriebenen Stellplatz passt, vor allem im Hinblick auf ihren " +
"Lichtbedarf.\n\n" +
`Pflanze: "${species}". ${description} ${careNotes}\n\n` +
`Stellplatz — Lichtverhältnisse: "${lightCategory}". ${lightAssessment}\n\n` +
"Sprache: Deutsch, einfach und freundlich (auch für ältere Menschen gut " +
"verständlich, keine Fachbegriffe ohne Erklärung). Sprich die Person " +
"immer mit „du“ an, niemals mit „Sie“. Schreibe reinen Fließtext ohne " +
"HTML-Tags und ohne Markdown-Formatierung. Antworte NUR mit JSON im " +
'Format {"stars": <Ganzzahl 1-5, 5 = optimal geeignet>, ' +
'"reasoning": "<1-2 Sätze Begründung>"}.',
anthropicApiKey.value()
);
}
);