mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 02:41:09 +02:00
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>
97 lines
3.2 KiB
TypeScript
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;
|
|
}
|