chore(mana): plants + who aus unified-App entfernen
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Build mana-search (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build quotes-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run

Plants → Herbatrium (herbatrium.mana.how, LIVE seit 2026-05-17),
Who → eigenständiger Bun-Stack auf who.mana.how (außerhalb des
managarten-Repos, deployt nativ unter PM2 auf dem Mac Mini).

Gelöscht / abgebaut:
- Module: apps/mana/.../modules/{plants,who} + Routen + Locales +
  routes/api/v1/who Proxy
- Top-Level: apps/plants/
- Backend: apps/api/src/modules/{plants,who} + scripts/generate-who-
  dossiers.ts + RESOURCE_MODULES + app.route()-Mounts
- shared-branding: APP_BRANDING, APP_ICONS, MANA_APPS, PlantsLogo
- credits AI_PLANT_ANALYSIS, shared-utils analytics PlantsEvents,
  spiral-db MANA_APP_INDEX plants
- Cross-Module: PlantWateringWidget, time-blocks/types,
  seed-registry PLANTS_GUEST_SEED, crypto-registry plants +
  plaintext-allowlist plantPhotos/plantTags/wateringLogs/wateringSchedules
- Dashboard: 'plant-watering' Widget, requiredBackend 'plants',
  WIDGET_REGISTRY-Eintrag
- Registries: app-registry/apps.ts + categories + help-content +
  module-registry + splitscreen + hooks.server APP_SUBDOMAINS +
  quick-input registry + data/tools/init
- Infrastruktur: cloudflared plants.mana.how, docker-compose
  CORS_ORIGINS + MinIO plants-storage Bucket, prometheus probe,
  package.json plants:dev Scripts, i18n locales plants+who Strings
- who.mana.how / who-api.mana.how Standalone-Routes BLEIBEN
  (PM2-Container auf Mac Mini, eigenständige Auth/SQLite/LLM-Keys)

Dexie v62 (Nachholung) + v63 Migrations:
- v62: dropped meals, goals, foodFavorites, mealTags, wardrobeGarments,
  wardrobeOutfits + images-Schema-Index ohne wardrobe-FKs + Upgrade-
  Callback strippt wardrobeOutfitId/wardrobeGarmentId aus Image-Rows.
  (Migration war im vorherigen Commit nicht im File gelandet, jetzt
  nachgeholt.)
- v63: dropped plants, plantPhotos, wateringSchedules, wateringLogs,
  plantTags, whoGames, whoMessages.

Test/Doku:
- module-registry.test.ts Snapshot: plants-Eintrag entfernt,
  whoGames/whoMessages aus LEGACY_TABLES (werden jetzt gedroppt)

mana-web svelte-check 0/0, snapshot test 10/10, streaks 5/5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-18 14:25:45 +02:00
parent 92f0b3ae06
commit 9a6e51b7a3
119 changed files with 53 additions and 11717 deletions

View file

@ -1,332 +0,0 @@
/* eslint-disable no-console */
/**
* Generate character dossiers for the Who module.
*
* One-shot research task: for every character in CHARACTERS, ask a strong
* cloud LLM (default: google/gemini-2.5-pro via mana-llm) to research the
* person and emit a structured CharacterDossier as JSON. The dossier
* captures voice, values, era, role, achievements, anecdotes, and the
* forbidden-early-words list everything the small runtime model needs
* to roleplay convincingly without making things up.
*
* Run:
* bun run apps/api/scripts/generate-who-dossiers.ts # missing only
* bun run apps/api/scripts/generate-who-dossiers.ts --force # overwrite all
* bun run apps/api/scripts/generate-who-dossiers.ts --id 33 # one character
* bun run apps/api/scripts/generate-who-dossiers.ts --model anthropic/claude-3.5-sonnet
*
* Output: apps/api/src/modules/who/data/dossiers/{id}.json one file per
* character, gitted, manually editable. The runtime loader picks them up
* automatically.
*
* Calls Google's Gemini API directly via its OpenAI-compatible endpoint
* (https://generativelanguage.googleapis.com/v1beta/openai/chat/completions)
* rather than going through mana-llm keeps the one-shot research task
* decoupled from the shared LLM gateway. Requires GOOGLE_API_KEY (or
* GOOGLE_GENAI_API_KEY) to be exported. The repo stores it in .env.secrets.
*
* This is intentionally idempotent and crash-resumable: each character is
* written immediately after a successful response, so a partial run can be
* resumed by re-invoking without --force.
*/
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { CHARACTERS } from '../src/modules/who/data/characters';
import {
CharacterDossierSchema,
type CharacterDossier,
} from '../src/modules/who/data/dossier-types';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DOSSIER_DIR = join(__dirname, '../src/modules/who/data/dossiers');
const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions';
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || process.env.GOOGLE_GENAI_API_KEY;
const MANA_LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
// Parse CLI flags
const args = process.argv.slice(2);
const FORCE = args.includes('--force');
// Provider switch: by default use Google Gemini (cloud, strong quality).
// --via-mana-llm flips to the local LLM gateway, useful as a fallback when
// the cloud free-tier quota is exhausted.
const VIA_MANA_LLM = args.includes('--via-mana-llm');
const ID_ARG = (() => {
const i = args.indexOf('--id');
if (i === -1) return null;
const n = Number(args[i + 1]);
return Number.isFinite(n) ? n : null;
})();
const MODEL = (() => {
const i = args.indexOf('--model');
const fallback = VIA_MANA_LLM ? 'ollama/gemma3:27b' : 'gemini-2.5-pro';
if (i === -1) return fallback;
return args[i + 1] ?? fallback;
})();
// ─── Prompt construction ────────────────────────────────────
//
// The model gets the character's name and category as the only seed.
// Everything else — facts, voice, hints — it must research and emit as
// validated JSON. We give it the exact JSON schema we want (paraphrased
// in prose, since gemini-2.5-pro follows prose schema descriptions
// better than raw JSON Schema in some cases).
function buildResearchPrompt(name: string, category: string): string {
return `Du erstellst ein Dossier über die historische Person "${name}" (Kategorie: ${category}) für ein KI-Ratespiel. Ein KLEINES Sprachmodell (gemma3:4b) wird dieses Dossier später nutzen, um die Person im Dialog zu verkörpern, ohne ihren Namen zu verraten. Deine Recherche muss daher faktentreu, konkret und handlungsleitend sein.
WICHTIG: Antworte AUSSCHLIESSLICH mit gültigem JSON. Kein Markdown, keine Erklärung, kein Code-Fence. Nur das JSON-Objekt. Alle Texte müssen in Deutsch (de) UND Englisch (en) vorhanden sein.
Schema:
{
"voice": { "de": string, "en": string },
// 2-3 Sätze: Sprechstil, Tonfall, sprachliche Eigenheiten der Person.
// KEINE identitätsverratenden Fakten (kein Name, Beruf, Ort, Werk).
// Beispiel für Konfuzius: "Spricht in kurzen, bedeutungsvollen Sätzen.
// Antwortet auf Fragen oft mit Gegenfragen. Ruhig, geduldig, lehrhaft."
"values": { "de": [string, ...], "en": [string, ...] },
// 3-5 Stichworte zu Werten/Charakter (z.B. "Neugier", "Ehre", "Mitgefühl")
"era": { "de": string, "en": string },
// Vage Zeitangabe, z.B. "spätes 2. Jahrtausend v. Chr." oder "Renaissance"
"region": { "de": string, "en": string },
// Vage geografische Angabe, z.B. "Niltal" oder "Norditalien"
"role": { "de": string, "en": string },
// Beruf/Stand in 2-4 Worten, z.B. "Herrscherin", "Naturphilosoph"
"achievements": { "de": [string, ...], "en": [string, ...] },
// 5-8 KONKRETE, historisch verifizierte Errungenschaften/Werke.
// Jeweils 1-2 Sätze. Diese werden später als Anekdoten freigegeben.
"anecdotes": { "de": [string, ...], "en": [string, ...] },
// 5-8 charakteristische, KURZE Anekdoten oder Episoden (1-2 Sätze).
// Müssen historisch belegt sein — KEINE Halluzinationen.
"relationships": { "de": [string, ...], "en": [string, ...] },
// 3-5 wichtige Personen + Verhältnis. 1 Satz pro Eintrag.
// z.B. "Thutmosis III. — mein Stiefsohn und Nachfolger"
"forbiddenEarly": [string, ...],
// 8-15 Wörter/Begriffe, die in den ersten Spielzügen NICHT fallen
// dürfen, weil sie die Person sofort verraten würden. Sprachneutral.
// ENTHÄLT IMMER: alle Bestandteile des eigenen Namens (Vor-, Nach-,
// Bei-Name), sowie die offensichtlichsten Schlagwörter (wichtigster
// Ort, wichtigstes Werk, wichtigster Titel, ggf. Ehepartner).
// Beispiel für Hatschepsut: ["Hatschepsut", "Pharaonin", "Pharao",
// "Ägypten", "Niltal", "Deir el-Bahari", "Amun", "Thutmosis"]
"commonWrongGuesses": [
{
"guess": string,
"correction": { "de": string, "en": string }
},
...
],
// 3-6 typische Fehlraten + wie der Charakter sie höflich korrigiert,
// OHNE den eigenen Namen zu nennen. Die "guess" ist der Name, den
// ein User raten könnte. Die "correction" ist im Charakter formuliert.
// Beispiel für Hatschepsut + guess "Kleopatra": "Nein, ich lebte
// viele Jahrhunderte vor jener letzten Königin. Mein Reich war
// jünger und mein Stil ein anderer."
"hints": {
"vague": { "de": string, "en": string },
"medium": { "de": string, "en": string },
"strong": { "de": string, "en": string }
}
// Drei Hinweise mit aufsteigender Schwierigkeit:
// - vague: Sehr indirekt, nur Stimmung/Epoche. Darf KEINE Wörter
// aus forbiddenEarly enthalten.
// - medium: Tätigkeitsfeld + grobe Zeit. Wenige forbiddenEarly-Wörter ok.
// - strong: Konkreter Hinweis, fast verraten. Alles erlaubt außer dem Namen.
}
EXTREM WICHTIG Struktur der Listen-Felder:
Jedes Feld vom Typ "Liste von Strings" (values, achievements, anecdotes, relationships) MUSS exakt diese Form haben:
{
"de": ["erster Eintrag auf Deutsch", "zweiter Eintrag auf Deutsch", "..."],
"en": ["first entry in English", "second entry in English", "..."]
}
NICHT als flache Liste \`["..."]\`. NICHT als Liste mit Sprachpaaren. IMMER ein Objekt mit zwei Schlüsseln "de" und "en", deren Werte jeweils Arrays von Strings sind.
Recherche-Anforderungen:
1. Alle Fakten müssen historisch korrekt und verifizierbar sein. Lieber weglassen als raten.
2. Anekdoten müssen die Person CHARAKTERISIEREN, nicht nur Daten auflisten.
3. Achte besonders auf forbiddenEarly vergisst du hier ein offensichtliches Wort, ist das Spiel kaputt.
4. Schreibe DE und EN unabhängig voneinander, nicht maschinell übersetzt.
5. Antworte NUR mit dem JSON-Objekt. Beginne deine Antwort mit "{".`;
}
// ─── LLM call ───────────────────────────────────────────────
async function callLLM(prompt: string): Promise<unknown> {
const url = VIA_MANA_LLM ? `${MANA_LLM_URL}/v1/chat/completions` : GEMINI_URL;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (!VIA_MANA_LLM) {
if (!GOOGLE_API_KEY) {
throw new Error(
'GOOGLE_API_KEY is not set. Export it before running:\n' +
' export GOOGLE_API_KEY=$(grep ^GOOGLE_API_KEY .env.secrets | cut -d= -f2)'
);
}
headers.Authorization = `Bearer ${GOOGLE_API_KEY}`;
}
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
model: MODEL,
messages: [
{
role: 'system',
content:
'Du bist ein historischer Recherche-Assistent. Du antwortest ausschließlich mit gültigem JSON, niemals mit Markdown oder Erklärungen.',
},
{ role: 'user', content: prompt },
],
temperature: 0.3,
// 8k is enough headroom for the full bilingual dossier (DE+EN
// doubles every text field). Anything less risks truncation
// mid-string and a JSON parse error.
max_tokens: 8000,
response_format: { type: 'json_object' },
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`LLM HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const raw = data.choices?.[0]?.message?.content ?? '';
if (!raw.trim()) {
throw new Error('LLM returned empty content');
}
// Strip accidental markdown fences if the model adds them despite the prompt
const cleaned = raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```\s*$/i, '')
.trim();
try {
return JSON.parse(cleaned);
} catch (err) {
throw new Error(
`LLM output is not valid JSON: ${err instanceof Error ? err.message : String(err)}\n--- raw output (first 500 chars) ---\n${cleaned.slice(0, 500)}`
);
}
}
// ─── Per-character workflow ─────────────────────────────────
async function generateOne(character: (typeof CHARACTERS)[number]): Promise<void> {
const outPath = join(DOSSIER_DIR, `${character.id}.json`);
if (!FORCE && existsSync(outPath)) {
console.log(` ⊘ skip ${character.id} ${character.name} (exists, use --force to regen)`);
return;
}
console.log(` ⟳ generating ${character.id} ${character.name}`);
const prompt = buildResearchPrompt(character.name, character.category);
let parsedJson: unknown;
try {
parsedJson = await callLLM(prompt);
} catch (err) {
console.error(`${character.id} ${character.name}: LLM call failed`);
console.error(` ${err instanceof Error ? err.message : String(err)}`);
return;
}
// Inject id + name (the model doesn't know the canonical id, and we
// want the dossier to self-identify so the loader can sanity-check)
const withIds = {
id: character.id,
name: character.name,
...(parsedJson as object),
};
let dossier: CharacterDossier;
try {
dossier = CharacterDossierSchema.parse(withIds);
} catch (err) {
console.error(`${character.id} ${character.name}: schema validation failed`);
console.error(` ${err instanceof Error ? err.message : String(err)}`);
// Save the raw output for manual inspection
const rawPath = join(DOSSIER_DIR, `${character.id}.raw.json`);
writeFileSync(rawPath, JSON.stringify(withIds, null, 2));
console.error(` raw output saved to ${rawPath}`);
return;
}
// Soft sanity check: forbiddenEarly should contain at least one part
// of the character's name. Models occasionally forget this and the
// resulting puzzle is broken.
const nameParts = character.name
.toLowerCase()
.split(/\s+/)
.filter((p) => p.length >= 4);
const forbiddenLower = dossier.forbiddenEarly.map((s) => s.toLowerCase());
const hasNamePart = nameParts.some((p) => forbiddenLower.some((f) => f.includes(p)));
if (!hasNamePart) {
console.warn(
`${character.id} ${character.name}: forbiddenEarly does not contain any name part — manual review recommended`
);
}
writeFileSync(outPath, JSON.stringify(dossier, null, 2) + '\n');
console.log(`${character.id} ${character.name}`);
}
// ─── Main ───────────────────────────────────────────────────
async function main() {
if (!existsSync(DOSSIER_DIR)) {
mkdirSync(DOSSIER_DIR, { recursive: true });
}
const targets = ID_ARG ? CHARACTERS.filter((c) => c.id === ID_ARG) : CHARACTERS;
if (targets.length === 0) {
console.error(`No characters matched (id=${ID_ARG}).`);
process.exit(1);
}
console.log(`Generating ${targets.length} dossier(s) using model: ${MODEL}`);
console.log(
`Provider: ${VIA_MANA_LLM ? `mana-llm (${MANA_LLM_URL})` : `Google Gemini (${GEMINI_URL})`}`
);
console.log(`Output dir: ${DOSSIER_DIR}`);
console.log(FORCE ? 'Mode: --force (overwrite existing)' : 'Mode: skip existing');
console.log('');
// Pace requests at ~7 seconds per call to stay under Gemini Flash's
// free-tier RPM limit (~10 RPM). Skipped for the first item and for
// single-id runs since neither risks hitting the limit.
for (let i = 0; i < targets.length; i++) {
const character = targets[i]!;
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, 7000));
}
await generateOne(character);
}
console.log('\nDone.');
}
main().catch((err) => {
console.error('Fatal:', err);
process.exit(1);
});