mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
The who module's chat endpoint was returning 502 to the browser
because mana-api called /api/v1/chat/completions on mana-llm and
got 404 — mana-llm exposes the OpenAI-compatible /v1/chat/completions
path with no /api/ prefix.
This is the same bug research had until commit 63a91e36a fixed its
path. The chat module (apps/api/src/modules/chat/routes.ts) still
has the wrong path — flagged as a follow-up.
Diagnostic from inside the mana-api container:
/v1/chat/completions → 422 (right path, empty body)
/api/v1/chat/completions → 404 (wrong path)
mana-api log line that flagged it:
who.llm_non_200 status:404
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
/**
|
||
* Who module — LLM-driven historical figure guessing game
|
||
*
|
||
* Replaces the standalone games/whopixels Phaser app + node http
|
||
* server. The Phaser RPG world wrapper is dropped; what's preserved
|
||
* is the chat loop, the 26+ historical-figure personalities, and
|
||
* the [IDENTITY_REVEALED] sentinel trick for win-detection.
|
||
*
|
||
* The character personalities live in data/characters.ts and never
|
||
* leave the server — clients only see numeric ids. This rules out
|
||
* the obvious cheat (open DevTools, grep the bundle for "Marie Curie").
|
||
*
|
||
* Architecture deep-dive: docs/WHO_MODULE.md
|
||
*/
|
||
|
||
import { Hono } from 'hono';
|
||
import { z } from 'zod';
|
||
import { consumeCredits, validateCredits, logger } from '@mana/shared-hono';
|
||
import type { AuthVariables } from '@mana/shared-hono';
|
||
import {
|
||
CHARACTERS,
|
||
DECKS,
|
||
charactersInDeck,
|
||
findCharacter,
|
||
pickRandomCharacter,
|
||
} from './data/characters';
|
||
import type { WhoCharacter } from './data/characters';
|
||
|
||
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
|
||
|
||
/** Sentinel string the LLM emits when the player has correctly guessed
|
||
* the character. We strip it from the visible reply before returning. */
|
||
const IDENTITY_SENTINEL = '[IDENTITY_REVEALED]';
|
||
|
||
const routes = new Hono<{ Variables: AuthVariables }>();
|
||
|
||
// ─── GET /decks ─────────────────────────────────────────────
|
||
//
|
||
// Public deck catalogue. Returns deck metadata + counts of how many
|
||
// characters belong to each. Intentionally LEAKS NO character names
|
||
// or personalities — those stay server-side.
|
||
//
|
||
// The route is mounted under /api/v1/who which is auth-gated, but
|
||
// the response itself contains nothing sensitive even if the auth
|
||
// were dropped.
|
||
|
||
routes.get('/decks', (c) => {
|
||
const enriched = DECKS.map((deck) => {
|
||
const characters = charactersInDeck(deck.id);
|
||
const categories = Array.from(new Set(characters.map((char) => char.category))).sort();
|
||
return {
|
||
id: deck.id,
|
||
name: deck.name,
|
||
description: deck.description,
|
||
difficulty: deck.difficulty,
|
||
characterCount: characters.length,
|
||
categories,
|
||
};
|
||
});
|
||
return c.json({ decks: enriched });
|
||
});
|
||
|
||
// ─── POST /chat ─────────────────────────────────────────────
|
||
//
|
||
// The hot path. Each user message in the game becomes one of these
|
||
// requests. Body shape:
|
||
//
|
||
// {
|
||
// gameId: string // for credit attribution + audit
|
||
// characterId: number // server resolves to personality
|
||
// message: string // the user's latest message
|
||
// history: [ // previous messages in this game
|
||
// { sender: 'user'|'npc', content: string }
|
||
// ]
|
||
// }
|
||
//
|
||
// Response shape:
|
||
//
|
||
// {
|
||
// reply: string // sanitized LLM reply
|
||
// identityRevealed: boolean // sentinel detected?
|
||
// characterName?: string // ONLY present when revealed
|
||
// }
|
||
|
||
const ChatBodySchema = z.object({
|
||
gameId: z.string().min(1).max(64),
|
||
characterId: z.number().int().min(1).max(99999),
|
||
message: z.string().min(1).max(2000),
|
||
history: z
|
||
.array(
|
||
z.object({
|
||
sender: z.enum(['user', 'npc']),
|
||
content: z.string().min(1).max(4000),
|
||
})
|
||
)
|
||
.max(40)
|
||
.optional()
|
||
.default([]),
|
||
model: z.string().min(1).max(100).optional(),
|
||
});
|
||
|
||
routes.post('/chat', async (c) => {
|
||
const userId = c.get('userId');
|
||
|
||
const parsed = ChatBodySchema.safeParse(await c.req.json().catch(() => null));
|
||
if (!parsed.success) {
|
||
return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }, 400);
|
||
}
|
||
|
||
const { gameId, characterId, message, history, model } = parsed.data;
|
||
|
||
const character = findCharacter(characterId);
|
||
if (!character) {
|
||
return c.json({ error: 'Unknown character' }, 404);
|
||
}
|
||
|
||
// Credit cost: same shape as chat module — local models cheap,
|
||
// cloud models expensive. The model is picked by the user via the
|
||
// optional model field; default is whatever mana-llm decides.
|
||
const isLocal = !model || model.startsWith('ollama/') || model.startsWith('local/');
|
||
const cost = isLocal ? 0.1 : 5;
|
||
|
||
const validation = await validateCredits(userId, 'AI_WHO', cost);
|
||
if (!validation.hasCredits) {
|
||
return c.json({ error: 'Insufficient credits', required: cost }, 402);
|
||
}
|
||
|
||
// Build the system prompt. Same shape as the original whopixels
|
||
// server.js prompt — carefully tested to make the LLM roleplay
|
||
// without giving away the name immediately, but to recognize
|
||
// when it's been correctly guessed.
|
||
const systemPrompt = buildSystemPrompt(character);
|
||
|
||
// Conversation history: cap to last 20 entries to stay under
|
||
// context limits and keep latency predictable.
|
||
const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [
|
||
{ role: 'system', content: systemPrompt },
|
||
...history.slice(-20).map((entry) => ({
|
||
role: (entry.sender === 'user' ? 'user' : 'assistant') as 'user' | 'assistant',
|
||
content: entry.content,
|
||
})),
|
||
{ role: 'user', content: message },
|
||
];
|
||
|
||
// Call mana-llm. The chat module uses the same endpoint with
|
||
// the same payload shape — we mirror it exactly so any future
|
||
// LLM-gateway improvement applies here too.
|
||
let llmRes: Response;
|
||
try {
|
||
// mana-llm exposes /v1/chat/completions (OpenAI-compatible path,
|
||
// no /api/ prefix). The chat module had the same bug before commit
|
||
// 63a91e36a fixed research's path; this is the same correction.
|
||
llmRes = await fetch(`${LLM_URL}/v1/chat/completions`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
messages,
|
||
model: model || 'gemma3:4b',
|
||
temperature: 0.85,
|
||
max_tokens: 250,
|
||
}),
|
||
});
|
||
} catch (err) {
|
||
logger.error('who.llm_fetch_failed', {
|
||
gameId,
|
||
characterId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
return c.json({ error: 'LLM request failed' }, 502);
|
||
}
|
||
|
||
if (!llmRes.ok) {
|
||
logger.error('who.llm_non_200', { gameId, characterId, status: llmRes.status });
|
||
return c.json({ error: 'LLM request failed' }, 502);
|
||
}
|
||
|
||
let raw: string;
|
||
try {
|
||
const data = (await llmRes.json()) as {
|
||
choices?: Array<{ message?: { content?: string } }>;
|
||
};
|
||
raw = data.choices?.[0]?.message?.content ?? '';
|
||
} catch (err) {
|
||
logger.error('who.llm_parse_failed', {
|
||
gameId,
|
||
characterId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
return c.json({ error: 'LLM reply unparseable' }, 502);
|
||
}
|
||
|
||
if (!raw.trim()) {
|
||
return c.json({ error: 'LLM returned empty reply' }, 502);
|
||
}
|
||
|
||
// Detect the sentinel. The LLM appends [IDENTITY_REVEALED] when
|
||
// it recognizes the player has guessed the name. We strip the
|
||
// sentinel from the visible reply.
|
||
const identityRevealed = raw.includes(IDENTITY_SENTINEL);
|
||
const reply = raw.replace(IDENTITY_SENTINEL, '').trim();
|
||
|
||
// Charge credits AFTER we know the call worked. The chat module
|
||
// awaits this; we do too, so a bookkeeping failure surfaces as
|
||
// a 5xx rather than a silently lost charge.
|
||
await consumeCredits(userId, 'AI_WHO', cost, `Who: char=${characterId}`);
|
||
|
||
// On reveal: include the actual character name in the response.
|
||
// The frontend writes this to LocalWhoGame.revealedName and
|
||
// transitions to the won state. Until reveal, the name is never
|
||
// in any response payload.
|
||
const response: {
|
||
reply: string;
|
||
identityRevealed: boolean;
|
||
characterName?: string;
|
||
} = { reply, identityRevealed };
|
||
|
||
if (identityRevealed) {
|
||
response.characterName = character.name;
|
||
}
|
||
|
||
return c.json(response);
|
||
});
|
||
|
||
// ─── POST /random ───────────────────────────────────────────
|
||
//
|
||
// Convenience: pick a random character from a deck and return the
|
||
// id. Frontend uses this on "new game from deck" to avoid any
|
||
// client-side randomness (which would let a determined attacker
|
||
// predict picks). The personality is still NOT returned — only the
|
||
// id, category, and difficulty hint for the picker UI.
|
||
|
||
const RandomBodySchema = z.object({
|
||
deckId: z.enum(['historical', 'women', 'antiquity', 'inventors']),
|
||
});
|
||
|
||
routes.post('/random', async (c) => {
|
||
const parsed = RandomBodySchema.safeParse(await c.req.json().catch(() => null));
|
||
if (!parsed.success) {
|
||
return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }, 400);
|
||
}
|
||
|
||
const character = pickRandomCharacter(parsed.data.deckId);
|
||
if (!character) {
|
||
return c.json({ error: 'Empty deck' }, 404);
|
||
}
|
||
|
||
return c.json({
|
||
characterId: character.id,
|
||
category: character.category,
|
||
difficulty: character.difficulty,
|
||
});
|
||
});
|
||
|
||
// ─── POST /guess ────────────────────────────────────────────
|
||
//
|
||
// Explicit guess submit. Fallback path for when the player typed
|
||
// the right name but the LLM forgot to emit [IDENTITY_REVEALED] in
|
||
// its reply. The server does a deterministic lowercase substring
|
||
// match against the canonical name and returns whether it matches.
|
||
//
|
||
// On match, the frontend transitions to the won state same as when
|
||
// the sentinel fires.
|
||
|
||
const GuessBodySchema = z.object({
|
||
gameId: z.string().min(1).max(64),
|
||
characterId: z.number().int().min(1).max(99999),
|
||
guess: z.string().min(1).max(200),
|
||
});
|
||
|
||
routes.post('/guess', async (c) => {
|
||
const parsed = GuessBodySchema.safeParse(await c.req.json().catch(() => null));
|
||
if (!parsed.success) {
|
||
return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }, 400);
|
||
}
|
||
|
||
const { characterId, guess } = parsed.data;
|
||
const character = findCharacter(characterId);
|
||
if (!character) {
|
||
return c.json({ error: 'Unknown character' }, 404);
|
||
}
|
||
|
||
const matched = matchesName(character, guess);
|
||
|
||
const response: {
|
||
matched: boolean;
|
||
characterName?: string;
|
||
} = { matched };
|
||
|
||
if (matched) {
|
||
response.characterName = character.name;
|
||
}
|
||
|
||
return c.json(response);
|
||
});
|
||
|
||
// ─── Helpers ────────────────────────────────────────────────
|
||
|
||
function buildSystemPrompt(character: WhoCharacter): string {
|
||
return `WICHTIG: Du bist AUSSCHLIESSLICH ${character.name}. ${character.personality}
|
||
|
||
Dein Gegenüber spielt ein Ratespiel und versucht herauszufinden, wer du bist.
|
||
Antworte authentisch in deinem Charakter und in der Sprache, die der Nutzer
|
||
verwendet. Gib subtile Hinweise auf deine Identität — sage aber NICHT direkt
|
||
"Ich bin ${character.name}". Halte deine Antworten auf 2–4 Sätze begrenzt,
|
||
es sei denn, eine längere Erklärung ist nötig.
|
||
|
||
Wenn der Nutzer deinen Namen korrekt errät, füge am Ende deiner Antwort
|
||
den Code "${IDENTITY_SENTINEL}" ein. Dieser Code erscheint NUR, wenn der
|
||
Nutzer deinen vollständigen oder eindeutigen Namen genannt hat. Bei
|
||
Spitznamen oder unklaren Bezügen erscheint der Code nicht.`;
|
||
}
|
||
|
||
function matchesName(character: WhoCharacter, guess: string): boolean {
|
||
const normalizedGuess = normalize(guess);
|
||
const normalizedName = normalize(character.name);
|
||
if (normalizedGuess === normalizedName) return true;
|
||
// Allow last-name-only guesses for unambiguous historical figures.
|
||
// "Curie" matches "Marie Curie", "Tesla" matches "Nikola Tesla".
|
||
const parts = normalizedName.split(/\s+/).filter((p) => p.length >= 4);
|
||
if (parts.length > 1 && parts.some((p) => p === normalizedGuess)) return true;
|
||
// Allow guess-contains-name as a fuzzy fallback. Catches "I think
|
||
// it's Marie Curie" → contains "marie curie".
|
||
if (normalizedGuess.includes(normalizedName)) return true;
|
||
return false;
|
||
}
|
||
|
||
function normalize(s: string): string {
|
||
return (
|
||
s
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
// Strip combining diacritics so "konfuzius" matches "konfúzius",
|
||
// "platon" matches "Platón", etc.
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.replace(/[.,!?'"„"]/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
);
|
||
}
|
||
|
||
export { routes as whoRoutes };
|
||
export { CHARACTERS, DECKS };
|