managarten/apps/api/src/modules/who/data/dossier-loader.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

97 lines
3.2 KiB
TypeScript

/**
* Loads character dossiers at boot time.
*
* Dossiers live as JSON files at ./dossiers/{id}.json — one per character.
* They are generated by apps/api/scripts/generate-who-dossiers.ts (a strong
* cloud LLM does the research) and then consumed at runtime by the small
* model (gemma3:4b) in carefully staged chunks to keep gameplay coherent.
*
* This loader:
* 1. Reads every {id}.json under ./dossiers/
* 2. Validates each against CharacterDossierSchema (Zod)
* 3. Sanity-checks that the dossier id matches the filename
* 4. Builds a Map<id, dossier> for O(1) runtime lookup
*
* Missing dossiers are NOT a hard error — the prompt builder falls back to
* the legacy `personality` string on the WhoCharacter so the module keeps
* working during the rollout. We only warn at boot.
*/
/* eslint-disable no-console */
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { CharacterDossierSchema, type CharacterDossier } from './dossier-types';
import { CHARACTERS } from './characters';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DOSSIER_DIR = join(__dirname, 'dossiers');
let dossierCache: Map<number, CharacterDossier> | null = null;
function loadDossiers(): Map<number, CharacterDossier> {
const map = new Map<number, CharacterDossier>();
if (!existsSync(DOSSIER_DIR)) {
console.warn(
`[who] dossier directory missing at ${DOSSIER_DIR} — falling back to legacy personality strings for all characters. Run: bun run apps/api/scripts/generate-who-dossiers.ts`
);
return map;
}
const files = readdirSync(DOSSIER_DIR).filter(
(f) => f.endsWith('.json') && !f.endsWith('.raw.json')
);
for (const file of files) {
const path = join(DOSSIER_DIR, file);
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON.parse(raw);
const dossier = CharacterDossierSchema.parse(parsed);
// Filename must match the dossier id (catches accidental copies)
const expectedId = Number(file.replace('.json', ''));
if (dossier.id !== expectedId) {
console.warn(
`[who] dossier ${file}: id mismatch (file=${expectedId}, content=${dossier.id}) — skipping`
);
continue;
}
map.set(dossier.id, dossier);
} catch (err) {
console.warn(
`[who] failed to load dossier ${file}: ${err instanceof Error ? err.message : String(err)}`
);
}
}
// Report coverage so devs notice gaps quickly during rollout
const missing = CHARACTERS.filter((c) => !map.has(c.id));
if (missing.length > 0) {
console.warn(
`[who] ${map.size}/${CHARACTERS.length} dossiers loaded; missing ids: ${missing
.map((c) => `${c.id}(${c.name})`)
.join(', ')}`
);
} else {
console.log(`[who] loaded ${map.size}/${CHARACTERS.length} character dossiers`);
}
return map;
}
/** Returns the dossier for a character id, or undefined if not generated yet. */
export function getDossier(id: number): CharacterDossier | undefined {
if (!dossierCache) {
dossierCache = loadDossiers();
}
return dossierCache.get(id);
}
/** Test/dev helper: force a reload from disk. */
export function reloadDossiers(): void {
dossierCache = null;
}