mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 20:01:22 +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
97
apps/api/src/modules/who/data/dossier-loader.ts
Normal file
97
apps/api/src/modules/who/data/dossier-loader.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue