mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:21:09 +02:00
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:
parent
734f149596
commit
e77ae5d5eb
41 changed files with 5613 additions and 1 deletions
332
apps/api/scripts/generate-who-dossiers.ts
Normal file
332
apps/api/scripts/generate-who-dossiers.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue