This commit is contained in:
38
apps/worker/src/ai.test.ts
Normal file
38
apps/worker/src/ai.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('worker AI providers', () => {
|
||||
it('uses official DeepSeek JSON mode and validates the parsed response', async () => {
|
||||
process.env.AI_PROVIDER = 'deepseek'
|
||||
process.env.DEEPSEEK_API_KEY = 'secret'
|
||||
process.env.DEEPSEEK_MODEL = 'deepseek-v4-flash'
|
||||
const world = {
|
||||
title: 'Test Reach', genre: 'Science fiction', tone: 'Tense', premise: 'A sufficiently long premise for a generated private campaign world.',
|
||||
contentBoundaries: ['13+'],
|
||||
startingLocation: { id: 'location', kind: 'location', name: 'Gate', summary: 'A station beyond known space.', tags: [], secrets: [] },
|
||||
npcs: [1, 2, 3].map(index => ({ id: `npc-${index}`, kind: 'npc', name: `NPC ${index}`, summary: 'A useful person with their own agenda.', tags: [], secrets: [] })),
|
||||
factions: [1, 2].map(index => ({ id: `faction-${index}`, kind: 'faction', name: `Faction ${index}`, summary: 'An organization pursuing a hidden objective.', tags: [], secrets: [] })),
|
||||
hook: 'A sufficiently long hook that immediately gives the party something to investigate.',
|
||||
hiddenThreat: 'A hidden threat waits beyond the gate.',
|
||||
openingScene: 'The gate opens without warning, and an impossible signal calls every hero by name.',
|
||||
}
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body))
|
||||
expect(body.response_format).toEqual({ type: 'json_object' })
|
||||
expect(body.thinking).toEqual({ type: 'disabled' })
|
||||
expect(body.model).toBe('deepseek-v4-flash')
|
||||
expect(body.messages[0].content).toContain('JSON Schema')
|
||||
return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(world) } }] }), { status: 200 })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const { generateWorld } = await import('./ai')
|
||||
await expect(generateWorld([{ role: 'user', content: 'Create a science fiction frontier.' }])).resolves.toMatchObject({ title: 'Test Reach' })
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.deepseek.com/chat/completions', expect.any(Object))
|
||||
}, 15_000)
|
||||
})
|
||||
113
apps/worker/src/ai.ts
Normal file
113
apps/worker/src/ai.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { RoundPlanSchema, RoundResolutionSchema, WorldStarterSchema, moderate13Plus, type Character, type PlayerIntent, type RoundPlan, type RoundResolution, type WorldStarter } from '@dng/shared'
|
||||
import type { DiceRoll } from '@dng/shared'
|
||||
|
||||
type JsonSchema = Record<string, unknown>
|
||||
|
||||
interface AiProvider {
|
||||
name: 'openrouter' | 'deepseek'
|
||||
endpoint: string
|
||||
apiKey: string
|
||||
model: string
|
||||
headers: Record<string, string>
|
||||
providerOptions?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function getProvider(): AiProvider {
|
||||
const provider = (process.env.AI_PROVIDER ?? 'openrouter').toLowerCase()
|
||||
if (provider === 'deepseek') {
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY
|
||||
if (!apiKey) throw new Error('DEEPSEEK_API_KEY is not configured')
|
||||
return {
|
||||
name: 'deepseek',
|
||||
endpoint: process.env.DEEPSEEK_API_ENDPOINT ?? 'https://api.deepseek.com/chat/completions',
|
||||
apiKey,
|
||||
model: process.env.DEEPSEEK_MODEL ?? 'deepseek-v4-flash',
|
||||
headers: {},
|
||||
}
|
||||
}
|
||||
if (provider !== 'openrouter') throw new Error(`Unsupported AI_PROVIDER: ${provider}`)
|
||||
const apiKey = process.env.OPENROUTER_API_KEY
|
||||
if (!apiKey) throw new Error('OPENROUTER_API_KEY is not configured')
|
||||
return {
|
||||
name: 'openrouter',
|
||||
endpoint: process.env.OPENROUTER_API_ENDPOINT ?? 'https://openrouter.ai/api/v1/chat/completions',
|
||||
apiKey,
|
||||
model: process.env.OPENROUTER_MODEL ?? 'deepseek/deepseek-v4-flash',
|
||||
headers: {
|
||||
'HTTP-Referer': process.env.APP_URL ?? 'http://localhost:3000',
|
||||
'X-Title': 'Dungeons & Ground',
|
||||
},
|
||||
providerOptions: { require_parameters: true, data_collection: 'deny', allow_fallbacks: true },
|
||||
}
|
||||
}
|
||||
|
||||
function assert13Plus(...texts: string[]): void {
|
||||
const result = moderate13Plus(texts.join('\n'))
|
||||
if (!result.allowed) throw new Error(`Content policy rejected: ${result.categories.join(', ')}`)
|
||||
}
|
||||
|
||||
async function structuredRequest<T>(name: string, schema: JsonSchema, messages: Array<{ role: string; content: string }>, parse: (value: unknown) => T): Promise<T> {
|
||||
const provider = getProvider()
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 45_000)
|
||||
try {
|
||||
const response = await fetch(provider.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
...provider.headers,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
messages: provider.name === 'deepseek'
|
||||
? [
|
||||
{ role: 'system', content: `${messages[0]?.content ?? ''}\nReturn only valid JSON matching this JSON Schema: ${JSON.stringify(schema)}` },
|
||||
...messages.slice(1),
|
||||
]
|
||||
: messages,
|
||||
temperature: 0.65,
|
||||
max_tokens: 4000,
|
||||
...(provider.providerOptions ? { provider: provider.providerOptions } : {}),
|
||||
...(provider.name === 'deepseek' ? { thinking: { type: 'disabled' } } : {}),
|
||||
response_format: provider.name === 'deepseek'
|
||||
? { type: 'json_object' }
|
||||
: { type: 'json_schema', json_schema: { name, strict: true, schema } },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) throw new Error(`AI provider returned ${response.status}`)
|
||||
const payload = await response.json() as { choices?: Array<{ message?: { content?: string } }>; usage?: unknown }
|
||||
const content = payload.choices?.[0]?.message?.content
|
||||
if (!content) throw new Error('AI provider returned an empty response')
|
||||
return parse(JSON.parse(content))
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'; content: string }>): Promise<WorldStarter> {
|
||||
assert13Plus(...messages.map(message => message.content))
|
||||
const world = await structuredRequest('world_starter', WorldStarterSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'You are the Dungeons & Ground world coauthor. Create an original 13+ private TTRPG setting in any requested genre. Never use protected settings or characters. Return exactly three NPCs and two factions. Make the opening immediately playable.' },
|
||||
...messages,
|
||||
], value => WorldStarterSchema.parse(value))
|
||||
assert13Plus(world.title, world.premise, world.hook, world.hiddenThreat, world.openingScene)
|
||||
return world
|
||||
}
|
||||
|
||||
export async function planRound(input: { scene: string; intents: PlayerIntent[]; characters: Character[]; memories: string[] }): Promise<RoundPlan> {
|
||||
return structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'You plan one asynchronous TTRPG round. You may request checks and propose story events. Never invent dice results and never directly mutate mechanical state. The server owns rules, HP, inventory, statuses and randomness.' },
|
||||
{ role: 'user', content: JSON.stringify(input) },
|
||||
], value => RoundPlanSchema.parse(value))
|
||||
}
|
||||
|
||||
export async function narrateRound(input: { scene: string; intents: PlayerIntent[]; aiActions: RoundPlan['aiActions']; rolls: DiceRoll[]; permittedEvents: RoundPlan['proposedEvents'] }): Promise<RoundResolution> {
|
||||
const resolution = await structuredRequest('round_resolution', RoundResolutionSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'Narrate the resolved TTRPG round using the supplied, authoritative dice results. Do not change them. Keep player agency, maintain a 13+ rating, and end with a clear prompt for the next round.' },
|
||||
{ role: 'user', content: JSON.stringify(input) },
|
||||
], value => RoundResolutionSchema.parse(value))
|
||||
assert13Plus(resolution.narration, resolution.nextPrompt, resolution.memory?.summary ?? '')
|
||||
return resolution
|
||||
}
|
||||
12
apps/worker/src/env.ts
Normal file
12
apps/worker/src/env.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { config } from 'dotenv'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// pnpm runs filtered workspace scripts with apps/worker as the current working
|
||||
// directory. Resolve from this module so the monorepo root .env is found in
|
||||
// dev, while real process variables supplied by Railway/CI keep precedence.
|
||||
const rootEnvPath = fileURLToPath(new URL('../../../.env', import.meta.url))
|
||||
const result = config({ path: rootEnvPath })
|
||||
|
||||
if (result.error && 'code' in result.error && result.error.code !== 'ENOENT') {
|
||||
throw result.error
|
||||
}
|
||||
241
apps/worker/src/index.ts
Normal file
241
apps/worker/src/index.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import './env'
|
||||
import { Worker } from 'bullmq'
|
||||
import IORedis from 'ioredis'
|
||||
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck } from '@dng/game-engine'
|
||||
import { CharacterSchema, PlayerIntentSchema, makeId } from '@dng/shared'
|
||||
import { generateWorld, narrateRound, planRound } from './ai'
|
||||
|
||||
const redisUrl = process.env.REDIS_URL
|
||||
const supabaseUrl = process.env.SUPABASE_URL
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !serviceKey) {
|
||||
throw new Error('[worker] SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required.')
|
||||
} else {
|
||||
const databaseUrl = supabaseUrl
|
||||
const databaseServiceKey = serviceKey
|
||||
type JobType = 'generate-world' | 'resolve-round'
|
||||
interface JobPayload {
|
||||
id: string
|
||||
name: JobType
|
||||
entityId: string
|
||||
messages?: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
claimToken?: string
|
||||
}
|
||||
interface ClaimedJob {
|
||||
id: string
|
||||
job_type: JobType
|
||||
entity_id: string
|
||||
}
|
||||
|
||||
class SupabaseRequestError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly path: string,
|
||||
detail: string,
|
||||
) {
|
||||
const rpcName = path.startsWith('rpc/') ? path.slice('rpc/'.length) : null
|
||||
const message = status === 404 && rpcName
|
||||
? `Supabase RPC "${rpcName}" was not found. Apply supabase/migrations/0002_supabase_ai_queue.sql in the Supabase SQL Editor, then restart the worker.`
|
||||
: `Supabase returned ${status} for ${path}: ${detail}`
|
||||
super(message)
|
||||
this.name = 'SupabaseRequestError'
|
||||
}
|
||||
}
|
||||
|
||||
async function databaseRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(new URL(`/rest/v1/${path}`, databaseUrl), {
|
||||
...init,
|
||||
headers: {
|
||||
apikey: databaseServiceKey,
|
||||
Authorization: `Bearer ${databaseServiceKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
const detail = await response.text()
|
||||
throw new SupabaseRequestError(response.status, path, detail)
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function selectRows<T>(table: string, query: Record<string, string>): Promise<T[]> {
|
||||
return databaseRequest<T[]>(`${table}?${new URLSearchParams(query)}`)
|
||||
}
|
||||
|
||||
async function processWorld(job: JobPayload) {
|
||||
let messages = job.messages
|
||||
if (!messages) {
|
||||
const [session] = await selectRows<{ messages: Array<{ role: 'user' | 'assistant'; content: string }> }>('coauthor_sessions', {
|
||||
select: 'messages', id: `eq.${job.entityId}`, limit: '1',
|
||||
})
|
||||
if (!session) throw new Error('Coauthor session not found')
|
||||
messages = session.messages
|
||||
}
|
||||
const world = await generateWorld(messages)
|
||||
await databaseRequest(`coauthor_sessions?id=eq.${encodeURIComponent(job.entityId)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status: 'ready', generated_world: world, updated_at: new Date().toISOString() }),
|
||||
})
|
||||
await databaseRequest('rpc/complete_ai_job', {
|
||||
method: 'POST', body: JSON.stringify({ p_job_id: job.id, p_worker_id: job.claimToken ?? null }),
|
||||
})
|
||||
return world
|
||||
}
|
||||
|
||||
async function processRound(job: JobPayload) {
|
||||
const [round] = await selectRows<Record<string, any>>('rounds', {
|
||||
select: '*,campaigns!inner(*)',
|
||||
id: `eq.${job.entityId}`,
|
||||
limit: '1',
|
||||
})
|
||||
if (!round) throw new Error('Round not found')
|
||||
if (round.status === 'resolved') return { duplicate: true }
|
||||
|
||||
const [rawCharacters, rawIntents, rawMemories] = await Promise.all([
|
||||
selectRows<Record<string, any>>('characters', { select: '*', campaign_id: `eq.${round.campaign_id}` }),
|
||||
selectRows<Record<string, any>>('player_intents', { select: '*', round_id: `eq.${round.id}` }),
|
||||
selectRows<{ summary: string }>('memories', { select: 'summary', campaign_id: `eq.${round.campaign_id}`, order: 'importance.desc', limit: '12' }),
|
||||
])
|
||||
|
||||
const characters = rawCharacters.map(row => CharacterSchema.parse({
|
||||
id: row.id, name: row.name, concept: row.concept, controller: row.controller,
|
||||
userId: row.user_id, abilities: row.abilities, hp: row.hp, maxHp: row.max_hp,
|
||||
defense: row.defense, proficiency: row.proficiency, inventory: row.inventory, statuses: row.statuses,
|
||||
}))
|
||||
const intents = rawIntents.map(row => PlayerIntentSchema.parse({
|
||||
id: row.id, roundId: row.round_id, memberId: row.member_id, characterId: row.character_id,
|
||||
action: row.action, ready: row.ready, createdAt: row.created_at, updatedAt: row.updated_at,
|
||||
}))
|
||||
const scene = String(round.campaigns.current_scene ?? '')
|
||||
const plan = await planRound({ scene, intents, characters, memories: rawMemories.map(row => String(row.summary)) })
|
||||
const rolls = plan.checks.map(check => {
|
||||
const actor = characters.find(character => character.id === check.actorId)
|
||||
if (!actor) throw new Error(`Unknown check actor ${check.actorId}`)
|
||||
return resolveCheck(check, actor)
|
||||
})
|
||||
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
|
||||
const resolution = await narrateRound({ scene, intents, aiActions: plan.aiActions, rolls, permittedEvents })
|
||||
const safeEvents = resolution.events.filter(event => permittedEvents.some(permitted => JSON.stringify(permitted) === JSON.stringify(event)))
|
||||
const applied = applyMechanicalEvents(characters, safeEvents)
|
||||
|
||||
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_round_resolution' : 'rpc/commit_round_resolution', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
p_round_id: round.id,
|
||||
p_narration: resolution.narration,
|
||||
p_next_prompt: resolution.nextPrompt,
|
||||
p_rolls: rolls,
|
||||
p_events: safeEvents,
|
||||
p_character_states: applied.characters.map(character => ({ id: character.id, hp: character.hp, inventory: character.inventory, statuses: character.statuses })),
|
||||
p_memory: resolution.memory,
|
||||
p_idempotency_key: job.id,
|
||||
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),
|
||||
}),
|
||||
})
|
||||
return { narration: resolution.narration, rolls: rolls.length }
|
||||
}
|
||||
|
||||
async function processJob(job: JobPayload) {
|
||||
if (job.name === 'generate-world') return processWorld(job)
|
||||
if (job.name === 'resolve-round') return processRound(job)
|
||||
throw new Error(`Unknown job type: ${job.name}`)
|
||||
}
|
||||
|
||||
async function withLeaseHeartbeat<T>(jobId: string, workerId: string, task: () => Promise<T>): Promise<T> {
|
||||
let heartbeatError: unknown
|
||||
let renewing = false
|
||||
const heartbeat = setInterval(() => {
|
||||
if (renewing) return
|
||||
renewing = true
|
||||
void databaseRequest('rpc/renew_ai_job_lease', {
|
||||
method: 'POST', body: JSON.stringify({ p_job_id: jobId, p_worker_id: workerId }),
|
||||
}).catch(error => { heartbeatError = error }).finally(() => { renewing = false })
|
||||
}, 60_000)
|
||||
heartbeat.unref()
|
||||
try {
|
||||
const result = await task()
|
||||
if (heartbeatError) throw heartbeatError
|
||||
return result
|
||||
} finally {
|
||||
clearInterval(heartbeat)
|
||||
}
|
||||
}
|
||||
|
||||
if (redisUrl) {
|
||||
const connection = new IORedis(redisUrl, { maxRetriesPerRequest: null })
|
||||
const workerId = process.env.RAILWAY_REPLICA_ID ?? makeId('worker')
|
||||
const worker = new Worker('dng-ai', async job => {
|
||||
if (!job.id) throw new Error('BullMQ job id must match an ai_jobs id')
|
||||
const [claimed] = await databaseRequest<ClaimedJob[]>('rpc/claim_ai_job_by_id', {
|
||||
method: 'POST', body: JSON.stringify({ p_job_id: String(job.id), p_worker_id: workerId }),
|
||||
})
|
||||
if (!claimed) throw new Error(`AI job ${job.id} is not claimable`)
|
||||
try {
|
||||
return await withLeaseHeartbeat(claimed.id, workerId, () => processJob({
|
||||
id: claimed.id,
|
||||
name: claimed.job_type,
|
||||
entityId: claimed.entity_id,
|
||||
messages: job.data.messages,
|
||||
claimToken: workerId,
|
||||
}))
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await databaseRequest('rpc/retry_ai_job', {
|
||||
method: 'POST', body: JSON.stringify({ p_job_id: claimed.id, p_worker_id: workerId, p_error: message.slice(0, 2_000) }),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}, { connection, concurrency: 4, lockDuration: 120_000 })
|
||||
console.info('[worker] Dungeons & Ground worker ready (BullMQ)')
|
||||
|
||||
const close = async () => {
|
||||
await worker.close()
|
||||
await connection.quit()
|
||||
}
|
||||
process.once('SIGTERM', () => void close())
|
||||
process.once('SIGINT', () => void close())
|
||||
} else {
|
||||
const pollInterval = Math.max(250, Number(process.env.AI_JOB_POLL_INTERVAL_MS ?? 1_000))
|
||||
const workerId = process.env.RAILWAY_REPLICA_ID ?? makeId('worker')
|
||||
let stopped = false
|
||||
|
||||
const stop = () => { stopped = true }
|
||||
process.once('SIGTERM', stop)
|
||||
process.once('SIGINT', stop)
|
||||
console.info(`[worker] Dungeons & Ground worker ready (Supabase polling every ${pollInterval}ms)`)
|
||||
|
||||
while (!stopped) {
|
||||
try {
|
||||
const claimed = await databaseRequest<ClaimedJob[]>('rpc/claim_ai_job', {
|
||||
method: 'POST', body: JSON.stringify({ p_worker_id: workerId }),
|
||||
})
|
||||
const job = claimed[0]
|
||||
if (!job) {
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await withLeaseHeartbeat(job.id, workerId, () => processJob({ id: job.id, name: job.job_type, entityId: job.entity_id, claimToken: workerId }))
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[worker] ${job.job_type} ${job.id} failed: ${message}`)
|
||||
await databaseRequest('rpc/retry_ai_job', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ p_job_id: job.id, p_worker_id: workerId, p_error: message.slice(0, 2_000) }),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SupabaseRequestError && error.status === 404 && error.path.startsWith('rpc/')) {
|
||||
console.error(`[worker] ${error.message}`)
|
||||
process.exitCode = 1
|
||||
break
|
||||
}
|
||||
console.error('[worker] polling failed:', error)
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user