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>
This commit is contained in:
Till JS 2026-04-10 17:40:16 +02:00
parent 734f149596
commit e77ae5d5eb
41 changed files with 5613 additions and 1 deletions

View file

@ -0,0 +1,332 @@
/* 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);
});