feat(02-04): implement companion text module with API-first fallback strategy

- getGreeting: AI greeting with time-of-day context, falls back to static texts
- getLetterIntro: AI letter introduction with finger description, falls back to curated intros
- getForestComment: AI forest element comment, falls back to static comments
- All functions check config availability and rate limits before API calls
- System prompts enforce max 25 words, simple German, no performance praise
This commit is contained in:
2026-03-29 12:05:50 +02:00
parent 6fba02cbe9
commit 4b4aa8ecd9
+145
View File
@@ -0,0 +1,145 @@
/**
* Companion text module: AI-generated or fallback text for greetings,
* letter introductions, and forest comments.
*
* Strategy: Try Gemini API first (with rate-limit check), fall back
* to curated static texts when API is unavailable.
*/
import { companions } from "./characters";
import type { CompanionType } from "../types";
import { loadConfig } from "../api/config";
import { generateText, checkRateLimit, incrementApiCall } from "../api/gemini";
import {
type TimeOfDay,
getRandomFallbackGreeting,
getFallbackLetterIntro,
getRandomFallbackForestComment,
} from "./fallbacks";
function getTimeOfDay(): TimeOfDay {
const hour = new Date().getHours();
if (hour < 12) return "morgen";
if (hour < 17) return "mittag";
return "abend";
}
function getCompanion(type: CompanionType) {
return companions.find((c) => c.type === type)!;
}
export async function getGreeting(
characterType: CompanionType,
db: IDBDatabase,
): Promise<string> {
const timeOfDay = getTimeOfDay();
const config = await loadConfig();
if (!config) return getRandomFallbackGreeting(timeOfDay);
const canCall = await checkRateLimit(db, "text");
if (!canCall) return getRandomFallbackGreeting(timeOfDay);
const companion = getCompanion(characterType);
const systemPrompt = `Du bist ${companion.name}, ein(e) ${companion.personality} im Zauberwald.
Du sprichst mit einem 7-jaehrigen Kind, das Tippen lernt.
Regeln:
- Max. 2 kurze Saetze (insgesamt max. 25 Woerter)
- Einfache Sprache, kurze Woerter
- Freundlich und warm, nie belehrend
- Keine Bewertung von Leistung
- Kein Englisch
- Beziehe dich auf die Tageszeit: ${timeOfDay}`;
const result = await generateText(
"Schreib eine Begruessung.",
systemPrompt,
config,
);
if (!result) return getRandomFallbackGreeting(timeOfDay);
await incrementApiCall(db, "text");
return result;
}
export async function getLetterIntro(
characterType: CompanionType,
letter: string,
fingerDescription: string,
db: IDBDatabase,
): Promise<string> {
const config = await loadConfig();
if (!config) {
return (
getFallbackLetterIntro(letter)?.text ??
"Druecke die leuchtende Taste!"
);
}
const canCall = await checkRateLimit(db, "text");
if (!canCall) {
return (
getFallbackLetterIntro(letter)?.text ??
"Druecke die leuchtende Taste!"
);
}
const companion = getCompanion(characterType);
const systemPrompt = `Du bist ${companion.name}, ein(e) ${companion.personality} im Zauberwald.
Du stellst einem 7-jaehrigen Kind den Buchstaben ${letter} vor.
Regeln:
- Max. 2 Saetze (max. 25 Woerter)
- Erklaere, welcher Finger den Buchstaben drueckt: ${fingerDescription}
- Verwende eine bildhafte Eselsbruecke, die zum Wald/Natur/Tiere-Thema passt
- Einfache Sprache
- Kein Englisch`;
const result = await generateText(
"Stelle den Buchstaben vor.",
systemPrompt,
config,
);
if (!result) {
return (
getFallbackLetterIntro(letter)?.text ??
"Druecke die leuchtende Taste!"
);
}
await incrementApiCall(db, "text");
return result;
}
export async function getForestComment(
characterType: CompanionType,
elementDescription: string,
db: IDBDatabase,
): Promise<string> {
const config = await loadConfig();
if (!config) return getRandomFallbackForestComment();
const canCall = await checkRateLimit(db, "text");
if (!canCall) return getRandomFallbackForestComment();
const companion = getCompanion(characterType);
const systemPrompt = `Du bist ${companion.name}, ein(e) ${companion.personality} im Zauberwald.
Ein Kind hat gerade eine Tippuebung abgeschlossen. Im Wald ist ein neues Wesen erschienen: ${elementDescription}.
Regeln:
- Max. 2 kurze Saetze (insgesamt max. 25 Woerter)
- Druecke Staunen und Freude aus
- Einfache Sprache fuer 7-Jaehrige
- Kein Lob fuer Leistung, sondern Begeisterung ueber das neue Waldelement
- Kein Englisch`;
const result = await generateText(
"Kommentiere das neue Element.",
systemPrompt,
config,
);
if (!result) return getRandomFallbackForestComment();
await incrementApiCall(db, "text");
return result;
}