managarten/apps/api/scripts/generate-who-dossiers.ts
Till JS e77ae5d5eb feat(who): add character dossier system for staged fact disclosure
Pre-researched dossiers (37 JSON files, DE+EN) replace the old
personality strings as the source of truth for the Who guessing game.
A strong cloud LLM (Gemini 2.5 Flash) generates structured facts per
character — voice, values, achievements, anecdotes, relationships,
forbidden-early-words, and three-stage hints — so the small runtime
model (gemma3:4b) gets only what it needs per turn instead of raw
personality text that leaks the identity immediately.

- dossier-types.ts: Zod schema + TS types for CharacterDossier
- dossier-loader.ts: boot-time loader with validation + coverage report
- generate-who-dossiers.ts: one-shot generator script (Google Gemini
  or local mana-llm fallback, idempotent, --force/--id flags)
- 37 dossier JSON files in data/dossiers/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:40:16 +02:00

332 lines
14 KiB
TypeScript

/* 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);
});