leafittome/functions/src/index.ts
cschlaefke b665bae95f Diagnose-Feinschliff: Art-Abgleich, nur Fließtext, konsequentes Du
Die angegebene Art ist jetzt eine unbestätigte Angabe: Claude prüft
selbst, welche Pflanze zu sehen ist, beurteilt bei Abweichung die
Pflanze im Bild und meldet matchesSpecies=false — die App zeigt dann
einen Warnhinweis im Ergebnis-Sheet. Prompt verbietet HTML/Markdown
(kein <br>) und schreibt durchgängiges Duzen vor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:39:45 +02:00

301 lines
10 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 ?? [],
};
}
/** Ein Aufruf der Claude-API mit Bild und Prompt, erwartet reines JSON zurück. */
async function askClaude<T>(
imageBase64: string,
prompt: string,
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: [
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: imageBase64,
},
},
{type: "text", text: prompt},
],
},
],
}),
});
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.");
}
}
/**
* 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",
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",
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,
};
}
);