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 { 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( content: ClaudeContent[], apiKey: string ): Promise { 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( imageBase64: string, prompt: string, apiKey: string ): Promise { return callClaude( [ { 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(prompt: string, apiKey: string): Promise { return callClaude([{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( 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
o. Ä.) und ohne " + "Markdown-Formatierung. Antworte NUR mit JSON im Format " + '{"healthy": , ' + '"matchesSpecies": , " + '"summary": "", ' + '"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": "", "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( 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": "", ' + '"description": "<2-3 Sätze über die Pflanze>", ' + '"careNotes": "<2-3 Sätze konkrete Pflegetipps zu Gießen, Licht, Dünger>", ' + '"wateringIntervalDays": , "fertilizingIntervalDays": }. ' + "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( 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": " `"${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( "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": , ' + '"reasoning": "<1-2 Sätze Begründung>"}.', anthropicApiKey.value() ); } );