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>
432 lines
15 KiB
TypeScript
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()
|
|
);
|
|
}
|
|
);
|