feat(memory): implement stability stage
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -13,12 +13,17 @@ interface DraftSession {
|
||||
generated_world?: { title?: string; premise?: string } | null
|
||||
updated_at: string
|
||||
}
|
||||
interface UsageSummary {
|
||||
quota: { usedToday: number; dailyRequestLimit: number; dailyTokenLimit: number }
|
||||
totals: { requests: number; inputTokens: number; outputTokens: number; costUsd: number; averageLatencyMs: number }
|
||||
}
|
||||
|
||||
const { api } = useDngApi()
|
||||
const auth = useDngAuth()
|
||||
const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||
const campaigns = ref<CampaignRow[]>([])
|
||||
const drafts = ref<DraftSession[]>([])
|
||||
const usage = ref<UsageSummary | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const renamingCampaignId = ref<string | null>(null)
|
||||
@@ -43,6 +48,10 @@ function draftSummary(draft: DraftSession) {
|
||||
|| 'Continue your conversation with the coauthor.'
|
||||
}
|
||||
|
||||
function compactNumber(value: number) {
|
||||
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(value)
|
||||
}
|
||||
|
||||
function openRename(campaign: CampaignRow) {
|
||||
renamingCampaignId.value = campaign.id
|
||||
newName.value = campaign.title
|
||||
@@ -74,12 +83,14 @@ async function requestDelete(campaign: CampaignRow) {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await auth.restore()
|
||||
const [campaignResult, draftResult] = await Promise.all([
|
||||
const [campaignResult, draftResult, usageResult] = await Promise.all([
|
||||
api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns'),
|
||||
api<{ sessions: DraftSession[] }>('/api/v1/coauthor/sessions'),
|
||||
api<UsageSummary>('/api/v1/usage').catch(() => null),
|
||||
])
|
||||
campaigns.value = campaignResult.campaigns
|
||||
drafts.value = draftResult.sessions
|
||||
usage.value = usageResult
|
||||
} catch (cause) {
|
||||
const value = cause as { data?: { statusMessage?: string }; message?: string }
|
||||
error.value = value.data?.statusMessage ?? value.message ?? 'Could not load your campaigns.'
|
||||
@@ -97,6 +108,13 @@ onMounted(async () => {
|
||||
<NuxtLink to="/worlds/new" class="create-button"><span>+</span> CREATE A UNIVERSE</NuxtLink>
|
||||
</section>
|
||||
|
||||
<section v-if="usage" class="usage-deck" aria-label="AI usage dashboard">
|
||||
<div><small>AI REQUESTS / TODAY</small><b>{{ usage.quota.usedToday }}<em>/ {{ usage.quota.dailyRequestLimit }}</em></b></div>
|
||||
<div><small>TOKENS / 30 DAYS</small><b>{{ compactNumber(usage.totals.inputTokens + usage.totals.outputTokens) }}</b></div>
|
||||
<div><small>EST. COST / 30 DAYS</small><b>${{ usage.totals.costUsd.toFixed(3) }}</b></div>
|
||||
<div><small>AVG. AI LATENCY</small><b>{{ (usage.totals.averageLatencyMs / 1000).toFixed(1) }}s</b></div>
|
||||
</section>
|
||||
|
||||
<div class="filter-row">
|
||||
<button v-for="item in ['all','active','drafts']" :key="item" :class="{active:filter===item}" @click="filter=item as typeof filter">{{ item }}</button>
|
||||
<span>{{ campaigns.length }} CAMPAIGNS · {{ drafts.length }} DRAFTS</span>
|
||||
@@ -152,6 +170,7 @@ onMounted(async () => {
|
||||
.dash-head p{color:var(--muted)}
|
||||
.create-button{display:flex;align-items:center;gap:18px;padding:18px 22px;background:var(--acid);color:#090909;text-decoration:none;font:600 10px var(--mono);letter-spacing:.12em}
|
||||
.create-button span{font-size:20px}
|
||||
.usage-deck{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:34px;border:1px solid var(--line);background:#0d0d0c}.usage-deck>div{display:grid;gap:10px;padding:18px 20px;border-right:1px solid var(--line)}.usage-deck>div:last-child{border-right:0}.usage-deck small{font:500 7px var(--mono);letter-spacing:.14em;color:var(--muted)}.usage-deck b{font:600 20px var(--mono);color:var(--acid)}.usage-deck em{margin-left:4px;color:var(--muted);font:500 9px var(--mono);font-style:normal}
|
||||
.filter-row{display:flex;align-items:center;gap:10px;margin:58px 0 24px;border-bottom:1px solid var(--line)}
|
||||
.filter-row button{padding:0 4px 16px;margin-right:18px;background:none;border:0;color:var(--muted);font:500 9px var(--mono);text-transform:uppercase;letter-spacing:.14em}
|
||||
.filter-row button.active{color:var(--ink);border-bottom:2px solid var(--acid)}
|
||||
@@ -188,6 +207,6 @@ onMounted(async () => {
|
||||
.rename-panel input:focus{border-color:var(--acid)}
|
||||
.rename-actions{display:flex;justify-content:flex-end;gap:10px}
|
||||
@media(max-width:1050px){.world-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}
|
||||
@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.usage-deck{grid-template-columns:repeat(2,1fr)}.usage-deck>div:nth-child(2){border-right:0}.usage-deck>div:nth-child(-n+2){border-bottom:1px solid var(--line)}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}
|
||||
@media(max-width:520px){.dash-head h1{font-size:clamp(34px,12vw,52px)}.create-button{width:100%;justify-content:center}.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}.world-info div{align-items:flex-start;flex-direction:column}.system-strip{flex-direction:column}}
|
||||
</style>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
|
||||
const visibleMembers = members.map(member => ({ ...member, profile: profileById.get(String(member.user_id)) ?? null }))
|
||||
const visibleRounds = rounds.slice().reverse()
|
||||
const visibleRoundIds = visibleRounds.map(item => String(item.id))
|
||||
const [intents, events, diceRolls] = await Promise.all([
|
||||
const [intents, events, diceRolls, sceneStates, questStates, relationships, memories, storySummaries, auditEntries] = await Promise.all([
|
||||
round ? stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`player_intents?select=id,round_id,member_id,character_id,action,ready,created_at,updated_at&round_id=eq.${String(round.id)}&order=created_at.asc`,
|
||||
) : Promise.resolve([]),
|
||||
@@ -50,8 +50,42 @@ export default defineEventHandler(async (event) => {
|
||||
visibleRoundIds.length ? stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`dice_rolls?select=id,round_id,actor_id,target_id,check_kind,formula,rolls,kept,modifier,total,difficulty,success,created_at&round_id=in.(${visibleRoundIds.join(',')})&order=created_at.asc`,
|
||||
) : Promise.resolve([]),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`scene_states?select=campaign_id,location_id,summary,active_entity_ids,tags,through_round,updated_at&campaign_id=eq.${campaignId}&limit=1`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`quest_states?select=campaign_id,quest_entity_id,status,summary,tags,through_round,updated_at&campaign_id=eq.${campaignId}&order=updated_at.desc`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`relationships?select=source_entity_id,target_entity_id,score,notes&campaign_id=eq.${campaignId}&order=score.desc&limit=30`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`memories?select=id,round_id,summary,importance,tags,entity_ids,created_at&campaign_id=eq.${campaignId}&order=importance.desc,created_at.desc&limit=20`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`story_summaries?select=id,through_round,summary,created_at&campaign_id=eq.${campaignId}&order=through_round.desc&limit=1`,
|
||||
),
|
||||
access.owner ? stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`audit_entries?select=id,actor_id,action,entity_type,entity_id,before_state,after_state,created_at&campaign_id=eq.${campaignId}&order=created_at.desc&limit=50`,
|
||||
) : Promise.resolve([]),
|
||||
])
|
||||
return { campaign, world: worlds[0] ?? null, members: visibleMembers, characters, round, rounds: visibleRounds, intents, events: events.slice().reverse(), diceRolls }
|
||||
return {
|
||||
campaign,
|
||||
world: worlds[0] ?? null,
|
||||
members: visibleMembers,
|
||||
characters,
|
||||
round,
|
||||
rounds: visibleRounds,
|
||||
intents,
|
||||
events: events.slice().reverse(),
|
||||
diceRolls,
|
||||
sceneState: sceneStates[0] ?? null,
|
||||
activeGoals: questStates.filter(quest => quest.status === 'active'),
|
||||
relationships,
|
||||
memories,
|
||||
storySummary: storySummaries[0] ?? null,
|
||||
auditEntries,
|
||||
}
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
requireCampaignAccess,
|
||||
requireStageTwoSafeText,
|
||||
requireStageTwoUser,
|
||||
stageTwoApiError,
|
||||
stageTwoRpc,
|
||||
stageTwoUuid,
|
||||
} from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({
|
||||
hp: z.number().int().min(0).optional(),
|
||||
inventory: z.array(z.string().trim().min(1).max(120)).max(50).optional(),
|
||||
statuses: z.array(z.string().trim().min(1).max(80)).max(12).optional(),
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
}).strict().refine(body => body.hp !== undefined || body.inventory !== undefined || body.statuses !== undefined, {
|
||||
message: 'At least one state field is required.',
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const characterId = stageTwoUuid(getRouterParam(event, 'characterId'), 'character id')
|
||||
await requireCampaignAccess(campaignId, user.id, true)
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
requireStageTwoSafeText([body.reason, ...(body.inventory ?? []), ...(body.statuses ?? [])].join('\n'))
|
||||
const { reason, ...patch } = body
|
||||
const state = await stageTwoRpc<Record<string, unknown>>('stage_six_adjust_character_state', {
|
||||
p_campaign_id: campaignId,
|
||||
p_owner_id: user.id,
|
||||
p_character_id: characterId,
|
||||
p_patch: patch,
|
||||
p_reason: reason,
|
||||
})
|
||||
return { characterId, state }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
requireCampaignAccess,
|
||||
requireStageTwoSafeText,
|
||||
requireStageTwoUser,
|
||||
stageSixConsumeAiQuota,
|
||||
stageSixRecordAiUsage,
|
||||
stageTwoApiError,
|
||||
stageTwoDatabase,
|
||||
stageTwoUuid,
|
||||
@@ -48,8 +50,10 @@ export default defineEventHandler(async (event) => {
|
||||
jsonSchema: CharacterDraftSchema.toJSONSchema(),
|
||||
})
|
||||
|
||||
await stageSixConsumeAiQuota(user.id, campaignId, 'character-draft')
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||
const startedAt = Date.now()
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(completion.endpoint, {
|
||||
@@ -62,10 +66,14 @@ export default defineEventHandler(async (event) => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The character coauthor is temporarily unavailable.' })
|
||||
const payload = await response.json()
|
||||
await stageSixRecordAiUsage({
|
||||
userId: user.id, campaignId, requestKind: 'character-draft', completion, payload, latencyMs: Date.now() - startedAt,
|
||||
}).catch(error => console.error('[web] could not record character draft usage:', error))
|
||||
|
||||
let character
|
||||
try {
|
||||
character = CharacterDraftSchema.parse(parseJsonCompletion(await response.json()))
|
||||
character = CharacterDraftSchema.parse(parseJsonCompletion(payload))
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid character draft.' })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageSixConsumeAiQuota, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
@@ -11,6 +11,7 @@ export default defineEventHandler(async (event) => {
|
||||
)
|
||||
if (!rounds[0]) throw createError({ statusCode: 404, statusMessage: 'Round not found.' })
|
||||
if (rounds[0].status !== 'failed') throw createError({ statusCode: 409, statusMessage: 'Only a failed round can be retried.' })
|
||||
await stageSixConsumeAiQuota(user.id, campaignId, 'retry-round')
|
||||
const jobId = await stageTwoRpc<string>('stage_two_retry_failed_round', {
|
||||
p_round_id: roundId,
|
||||
p_owner_id: user.id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { buildJsonCompletion, parseJsonCompletion } from '~/server/utils/ai-provider'
|
||||
import { CoauthorQuestionSchema, StoredCoauthorQuestionSchema } from '~/server/utils/coauthor-question'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageSixConsumeAiQuota, stageSixRecordAiUsage, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const MessageSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string().min(1).max(5000) })
|
||||
|
||||
@@ -34,8 +34,10 @@ export default defineEventHandler(async (event) => {
|
||||
schemaName: 'coauthor_question',
|
||||
jsonSchema: CoauthorQuestionSchema.toJSONSchema(),
|
||||
})
|
||||
await stageSixConsumeAiQuota(user.id, null, 'coauthor-question')
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||
const startedAt = Date.now()
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(completion.endpoint, {
|
||||
@@ -45,8 +47,12 @@ export default defineEventHandler(async (event) => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
|
||||
const payload = await response.json()
|
||||
await stageSixRecordAiUsage({
|
||||
userId: user.id, campaignId: null, requestKind: 'coauthor-question', completion, payload, latencyMs: Date.now() - startedAt,
|
||||
}).catch(error => console.error('[web] could not record coauthor question usage:', error))
|
||||
try {
|
||||
const parsed = CoauthorQuestionSchema.safeParse(parseJsonCompletion(await response.json()))
|
||||
const parsed = CoauthorQuestionSchema.safeParse(parseJsonCompletion(payload))
|
||||
if (parsed.success) question = parsed.data
|
||||
} catch {
|
||||
// Retry once when the provider ignores structured-output requirements.
|
||||
|
||||
61
apps/web/server/api/v1/usage.get.ts
Normal file
61
apps/web/server/api/v1/usage.get.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
interface UsageRow {
|
||||
campaign_id: string | null
|
||||
request_kind: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cost_usd: number | string
|
||||
latency_ms: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const since = new Date()
|
||||
since.setUTCDate(since.getUTCDate() - 30)
|
||||
const today = new Date()
|
||||
today.setUTCHours(0, 0, 0, 0)
|
||||
const [usage, quotaEvents] = await Promise.all([
|
||||
stageTwoDatabase<UsageRow[]>(
|
||||
`ai_usage?select=campaign_id,request_kind,input_tokens,output_tokens,cost_usd,latency_ms,created_at&user_id=eq.${user.id}&created_at=gte.${encodeURIComponent(since.toISOString())}&order=created_at.desc&limit=1000`,
|
||||
),
|
||||
stageTwoDatabase<Array<{ created_at: string }>>(
|
||||
`ai_quota_events?select=created_at&user_id=eq.${user.id}&created_at=gte.${encodeURIComponent(today.toISOString())}`,
|
||||
),
|
||||
])
|
||||
const campaignIds = [...new Set(usage.flatMap(row => row.campaign_id ? [row.campaign_id] : []))]
|
||||
const campaigns = campaignIds.length
|
||||
? await stageTwoDatabase<Array<{ id: string; title: string }>>(`campaigns?select=id,title&id=in.(${campaignIds.join(',')})`)
|
||||
: []
|
||||
const campaignNames = new Map(campaigns.map(campaign => [campaign.id, campaign.title]))
|
||||
const summarize = (rows: UsageRow[]) => {
|
||||
const latencies = rows.flatMap(row => row.latency_ms === null ? [] : [Number(row.latency_ms)])
|
||||
return {
|
||||
requests: rows.length,
|
||||
inputTokens: rows.reduce((sum, row) => sum + Number(row.input_tokens), 0),
|
||||
outputTokens: rows.reduce((sum, row) => sum + Number(row.output_tokens), 0),
|
||||
costUsd: rows.reduce((sum, row) => sum + Number(row.cost_usd), 0),
|
||||
averageLatencyMs: latencies.length ? Math.round(latencies.reduce((sum, value) => sum + value, 0) / latencies.length) : 0,
|
||||
}
|
||||
}
|
||||
const grouped = new Map<string, UsageRow[]>()
|
||||
for (const row of usage) {
|
||||
const key = row.campaign_id ?? 'coauthor'
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), row])
|
||||
}
|
||||
return {
|
||||
periodDays: 30,
|
||||
quota: { usedToday: quotaEvents.length, dailyRequestLimit: 60, dailyTokenLimit: 250000 },
|
||||
totals: summarize(usage),
|
||||
groups: [...grouped].map(([id, rows]) => ({
|
||||
id,
|
||||
label: id === 'coauthor' ? 'World coauthor' : campaignNames.get(id) ?? 'Private campaign',
|
||||
...summarize(rows),
|
||||
})).sort((left, right) => right.costUsd - left.costUsd),
|
||||
}
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CreateWorldRequestSchema, WorldStarterSchema, moderate13Plus } from '@dng/shared'
|
||||
import { buildJsonCompletion, parseJsonCompletion } from '../../utils/ai-provider'
|
||||
import { requireStageTwoUser } from '../../utils/stage-two-supabase'
|
||||
import { requireStageTwoUser, stageSixConsumeAiQuota, stageSixRecordAiUsage } from '../../utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
await requireStageTwoUser(event)
|
||||
const user = await requireStageTwoUser(event)
|
||||
const raw = await readBody(event)
|
||||
const prompt = typeof raw?.prompt === 'string' ? raw.prompt : ''
|
||||
const answers = raw?.answers && typeof raw.answers === 'object' ? raw.answers : {}
|
||||
@@ -24,15 +24,21 @@ export default defineEventHandler(async event => {
|
||||
throw createError({ statusCode: 500, statusMessage: error instanceof Error ? error.message : 'Invalid AI provider configuration.' })
|
||||
}
|
||||
|
||||
await stageSixConsumeAiQuota(user.id, null, 'world-preview')
|
||||
const startedAt = Date.now()
|
||||
const response = await fetch(completion.endpoint, {
|
||||
method: 'POST',
|
||||
headers: completion.headers,
|
||||
body: JSON.stringify(completion.body),
|
||||
})
|
||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
|
||||
const payload = await response.json()
|
||||
await stageSixRecordAiUsage({
|
||||
userId: user.id, campaignId: null, requestKind: 'world-preview', completion, payload, latencyMs: Date.now() - startedAt,
|
||||
}).catch(error => console.error('[web] could not record world preview usage:', error))
|
||||
let world
|
||||
try {
|
||||
world = WorldStarterSchema.parse(parseJsonCompletion(await response.json()))
|
||||
world = WorldStarterSchema.parse(parseJsonCompletion(payload))
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid world structure.' })
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildJsonCompletion, parseJsonCompletion, resolveAiProvider } from './ai-provider'
|
||||
import { buildJsonCompletion, parseAiUsage, parseJsonCompletion, resolveAiProvider } from './ai-provider'
|
||||
|
||||
const request = { system: 'Return JSON.', messages: [{ role: 'user' as const, content: 'Create a world' }], schemaName: 'world', jsonSchema: { type: 'object' } }
|
||||
|
||||
@@ -22,4 +22,11 @@ describe('AI provider adapter', () => {
|
||||
expect(() => parseJsonCompletion({ choices: [] })).toThrow()
|
||||
expect(parseJsonCompletion({ choices: [{ message: { content: '{"ok":true}' } }] })).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('normalizes provider usage telemetry', () => {
|
||||
expect(parseAiUsage({
|
||||
choices: [{ message: { content: '{}' } }],
|
||||
usage: { prompt_tokens: 120, completion_tokens: 30, cost: 0.0042 },
|
||||
})).toEqual({ inputTokens: 120, outputTokens: 30, costUsd: 0.0042 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,14 @@ const CompletionResponseSchema = z.object({
|
||||
choices: z.array(z.object({
|
||||
message: z.object({ content: z.string().min(1) }),
|
||||
})).min(1),
|
||||
usage: z.object({
|
||||
prompt_tokens: z.number().nonnegative().nullish(),
|
||||
completion_tokens: z.number().nonnegative().nullish(),
|
||||
input_tokens: z.number().nonnegative().nullish(),
|
||||
output_tokens: z.number().nonnegative().nullish(),
|
||||
cost: z.number().nonnegative().nullish(),
|
||||
total_cost: z.number().nonnegative().nullish(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
export type AiProvider = z.infer<typeof ProviderSchema>
|
||||
@@ -26,10 +34,18 @@ export interface JsonCompletionRequest {
|
||||
jsonSchema: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface JsonCompletion {
|
||||
export interface JsonCompletion {
|
||||
endpoint: string
|
||||
headers: Record<string, string>
|
||||
body: Record<string, unknown>
|
||||
provider: AiProvider
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface AiUsageSnapshot {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
costUsd: number
|
||||
}
|
||||
|
||||
const nonEmptyString = (value: unknown, name: string) => {
|
||||
@@ -73,6 +89,8 @@ export function buildJsonCompletion(config: AiRuntimeConfig, request: JsonComple
|
||||
if (provider.provider === 'deepseek') {
|
||||
return {
|
||||
endpoint: provider.endpoint,
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: {
|
||||
...common,
|
||||
@@ -88,6 +106,8 @@ export function buildJsonCompletion(config: AiRuntimeConfig, request: JsonComple
|
||||
|
||||
return {
|
||||
endpoint: provider.endpoint,
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json', 'X-Title': 'Dungeons & Ground' },
|
||||
body: {
|
||||
...common,
|
||||
@@ -101,3 +121,12 @@ export function parseJsonCompletion(payload: unknown): unknown {
|
||||
const completion = CompletionResponseSchema.parse(payload)
|
||||
return JSON.parse(completion.choices[0]!.message.content)
|
||||
}
|
||||
|
||||
export function parseAiUsage(payload: unknown): AiUsageSnapshot {
|
||||
const completion = CompletionResponseSchema.parse(payload)
|
||||
return {
|
||||
inputTokens: Math.max(0, Math.trunc(completion.usage?.prompt_tokens ?? completion.usage?.input_tokens ?? 0)),
|
||||
outputTokens: Math.max(0, Math.trunc(completion.usage?.completion_tokens ?? completion.usage?.output_tokens ?? 0)),
|
||||
costUsd: Math.max(0, completion.usage?.cost ?? completion.usage?.total_cost ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { H3Event } from 'h3'
|
||||
import { moderate13Plus } from '@dng/shared'
|
||||
import { parseAiUsage, type JsonCompletion } from './ai-provider'
|
||||
|
||||
const UuidSchema = z.string().uuid()
|
||||
const AuthEmailSchema = z.preprocess(
|
||||
@@ -87,6 +89,41 @@ export function stageTwoRpc<T>(name: string, body: Record<string, unknown>): Pro
|
||||
return stageTwoDatabase<T>(`rpc/${name}`, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
export function stageSixConsumeAiQuota(userId: string, campaignId: string | null, requestKind: string) {
|
||||
return stageTwoRpc<{ userRemaining: number; campaignRemaining: number | null; tokenRemaining: number }>('stage_six_consume_ai_quota', {
|
||||
p_user_id: userId,
|
||||
p_campaign_id: campaignId,
|
||||
p_request_kind: requestKind,
|
||||
})
|
||||
}
|
||||
|
||||
export async function stageSixRecordAiUsage(input: {
|
||||
userId: string
|
||||
campaignId: string | null
|
||||
requestKind: string
|
||||
completion: JsonCompletion
|
||||
payload: unknown
|
||||
latencyMs: number
|
||||
}): Promise<void> {
|
||||
const usage = parseAiUsage(input.payload)
|
||||
await stageTwoDatabase('ai_usage', {
|
||||
method: 'POST',
|
||||
prefer: 'return=minimal',
|
||||
body: JSON.stringify({
|
||||
usage_key: `${input.requestKind}:${randomUUID()}`,
|
||||
user_id: input.userId,
|
||||
campaign_id: input.campaignId,
|
||||
provider: input.completion.provider,
|
||||
request_kind: input.requestKind,
|
||||
model: input.completion.model,
|
||||
input_tokens: usage.inputTokens,
|
||||
output_tokens: usage.outputTokens,
|
||||
cost_usd: usage.costUsd,
|
||||
latency_ms: Math.max(0, Math.trunc(input.latencyMs)),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function requireStageTwoUser(event: H3Event): Promise<StageTwoUser> {
|
||||
const authorization = getHeader(event, 'authorization')
|
||||
if (!authorization?.startsWith('Bearer ') || authorization.length <= 7) {
|
||||
@@ -159,7 +196,8 @@ export function stageTwoApiError(error: unknown): never {
|
||||
})
|
||||
}
|
||||
const conflict = /duplicate key|already exists|not claimable/i.test(error.message)
|
||||
throw createError({ statusCode: conflict ? 409 : error.status >= 500 ? 502 : 400, statusMessage: error.message })
|
||||
const quota = /AI (?:rate limit|.*quota)/i.test(error.message)
|
||||
throw createError({ statusCode: quota ? 429 : conflict ? 409 : error.status >= 500 ? 502 : 400, statusMessage: error.message })
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
throw createError({ statusCode: 400, statusMessage: error.issues.map(issue => issue.message).join('; ') })
|
||||
|
||||
@@ -12,6 +12,20 @@ interface AiProvider {
|
||||
providerOptions?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AiUsageMeasurement {
|
||||
operation: string
|
||||
provider: AiProvider['name']
|
||||
model: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
costUsd: number
|
||||
latencyMs: number
|
||||
}
|
||||
|
||||
interface AiRequestOptions {
|
||||
onUsage?: (measurement: AiUsageMeasurement) => void | Promise<void>
|
||||
}
|
||||
|
||||
function getProvider(): AiProvider {
|
||||
const provider = (process.env.AI_PROVIDER ?? 'openrouter').toLowerCase()
|
||||
if (provider === 'deepseek') {
|
||||
@@ -46,10 +60,11 @@ function assert13Plus(...texts: string[]): void {
|
||||
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> {
|
||||
async function structuredRequest<T>(name: string, schema: JsonSchema, messages: Array<{ role: string; content: string }>, parse: (value: unknown) => T, options: AiRequestOptions = {}): Promise<T> {
|
||||
const provider = getProvider()
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 45_000)
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
const response = await fetch(provider.endpoint, {
|
||||
method: 'POST',
|
||||
@@ -77,8 +92,21 @@ async function structuredRequest<T>(name: string, schema: JsonSchema, messages:
|
||||
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 payload = await response.json() as {
|
||||
choices?: Array<{ message?: { content?: string } }>
|
||||
usage?: { prompt_tokens?: number | null; completion_tokens?: number | null; input_tokens?: number | null; output_tokens?: number | null; cost?: number | null; total_cost?: number | null }
|
||||
}
|
||||
const content = payload.choices?.[0]?.message?.content
|
||||
const usage = payload.usage
|
||||
await options.onUsage?.({
|
||||
operation: name,
|
||||
provider: provider.name,
|
||||
model: provider.model,
|
||||
inputTokens: Math.max(0, Math.trunc(usage?.prompt_tokens ?? usage?.input_tokens ?? 0)),
|
||||
outputTokens: Math.max(0, Math.trunc(usage?.completion_tokens ?? usage?.output_tokens ?? 0)),
|
||||
costUsd: Math.max(0, Number(usage?.cost ?? usage?.total_cost ?? 0)),
|
||||
latencyMs: Math.max(0, Date.now() - startedAt),
|
||||
})
|
||||
if (!content) throw new Error('AI provider returned an empty response')
|
||||
return parse(JSON.parse(content))
|
||||
} finally {
|
||||
@@ -86,41 +114,41 @@ async function structuredRequest<T>(name: string, schema: JsonSchema, messages:
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'; content: string }>): Promise<WorldStarter> {
|
||||
export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'; content: string }>, options: AiRequestOptions = {}): 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 SRD 5.2.1 TTRPG setting in any requested genre. Never use protected settings or characters. Return exactly three NPCs, two factions, and three distinct persistent AI companion heroes created specifically for this universe. Give each companion complete playable mechanics, exactly four fitting skill proficiencies, exactly two saving throw proficiencies, no more than one expertise chosen from their proficient skills, useful genre-appropriate equipment, and a persona that produces interesting party choices without overriding the human player. Make the opening immediately playable.' },
|
||||
...messages,
|
||||
], value => WorldStarterSchema.parse(value))
|
||||
], value => WorldStarterSchema.parse(value), options)
|
||||
// Moderate the entire typed object, including entity summaries, secrets and
|
||||
// boundaries—not just the headline fields shown in the first preview.
|
||||
assert13Plus(JSON.stringify(world))
|
||||
return world
|
||||
}
|
||||
|
||||
export async function planRound(input: RoundContext): Promise<RoundPlan> {
|
||||
export async function planRound(input: RoundContext, options: AiRequestOptions = {}): Promise<RoundPlan> {
|
||||
const plan = await structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'You plan one asynchronous SRD 5.2.1 TTRPG round. Human intents are authoritative and happen first. Then provide at most one action for each supplied aiCharacter, in the supplied order; never create AI actions for human-controlled characters. Request a d20 test only when the outcome is uncertain: ability, skill, savingThrow, deathSavingThrow, attack, or initiative. Skill checks require a skill and DC; saving throws require an ability and DC; attacks require an active targetId and use that target\'s server-owned Armor Class. Character rules profiles—not you—own proficiency, expertise, temporary HP, death saves, exhaustion, resistances and limited resources. Request a deathSavingThrow only for an unstable actor at 0 HP. Request separate damage or healing dice only after an applicable action; every damage event needs its SRD damageType. A resource event may consume only a named resource present on the character. A rest event uses item short or long. Never invent dice results and never directly mutate mechanical state. The server applies conditions, resistance, vulnerability, immunity, critical damage, HP and randomness.' },
|
||||
{ role: 'system', content: 'You plan one asynchronous SRD 5.2.1 TTRPG round. Human intents are authoritative and happen first. Then provide at most one action for each supplied aiCharacter, in the supplied order; never create AI actions for human-controlled characters. Request a d20 test only when the outcome is uncertain: ability, skill, savingThrow, deathSavingThrow, attack, or initiative. Skill checks require a skill and DC; saving throws require an ability and DC; attacks require an active targetId and use that target\'s server-owned Armor Class. Character rules profiles—not you—own proficiency, expertise, temporary HP, death saves, exhaustion, resistances and limited resources. Request a deathSavingThrow only for an unstable actor at 0 HP. Request separate damage or healing dice only after an applicable action; every damage event needs its SRD damageType. A resource event may consume only a named resource present on the character. A rest event uses item short or long. Never invent dice results and never directly mutate mechanical state. The server applies conditions, resistance, vulnerability, immunity, critical damage, HP and randomness. Use only supplied entity IDs for world-state events: relationship changes require distinct actorId and targetId plus a delta from -20 to 20; quest changes target a supplied quest entity and use item hidden, active, completed, or failed; a scene transition is a narrative event with item scene and may target a supplied location. Include only changes caused by this round.' },
|
||||
{ role: 'user', content: JSON.stringify(input) },
|
||||
], value => RoundPlanSchema.parse(value))
|
||||
], value => RoundPlanSchema.parse(value), options)
|
||||
assert13Plus(JSON.stringify(plan.aiActions), JSON.stringify(plan.proposedEvents))
|
||||
return plan
|
||||
}
|
||||
|
||||
export async function narrateRound(input: { context: RoundContext; actionSequence: Array<{ phase: 'human' | 'ai'; characterId: string; action: string }>; rolls: DiceRoll[]; permittedEvents: RoundPlan['proposedEvents'] }): Promise<RoundResolution> {
|
||||
export async function narrateRound(input: { context: RoundContext; actionSequence: Array<{ phase: 'human' | 'ai'; characterId: string; action: string }>; rolls: DiceRoll[]; permittedEvents: RoundPlan['proposedEvents'] }, options: AiRequestOptions = {}): Promise<RoundResolution> {
|
||||
const resolution = await structuredRequest('round_resolution', RoundResolutionSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'Narrate the resolved TTRPG round in the exact supplied actionSequence order: all human actions first, then AI/delegated hero actions. Use the authoritative dice results without changing 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))
|
||||
], value => RoundResolutionSchema.parse(value), options)
|
||||
assert13Plus(JSON.stringify(resolution))
|
||||
return resolution
|
||||
}
|
||||
|
||||
export async function summarizeStory(input: { previousSummary: string | null; rounds: Array<{ number: number; narration: string }> }): Promise<StorySummary> {
|
||||
export async function summarizeStory(input: { previousSummary: string | null; rounds: Array<{ number: number; narration: string }> }, options: AiRequestOptions = {}): Promise<StorySummary> {
|
||||
const result = await structuredRequest('story_summary', StorySummarySchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'Update a durable TTRPG campaign summary. Preserve established facts, goals, relationships and unresolved consequences. Use only the supplied previous summary and round narrations. Be concise and do not invent details.' },
|
||||
{ role: 'user', content: JSON.stringify(input) },
|
||||
], value => StorySummarySchema.parse(value))
|
||||
], value => StorySummarySchema.parse(value), options)
|
||||
assert13Plus(result.summary)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Worker } from 'bullmq'
|
||||
import IORedis from 'ioredis'
|
||||
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveChecks } from '@dng/game-engine'
|
||||
import { CharacterSchema, PlayerIntentSchema, makeId } from '@dng/shared'
|
||||
import { generateWorld, narrateRound, planRound, summarizeStory } from './ai'
|
||||
import { buildActionSequence, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
|
||||
import { generateWorld, narrateRound, planRound, summarizeStory, type AiUsageMeasurement } from './ai'
|
||||
import { buildActionSequence, buildRetrievalText, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
|
||||
|
||||
const redisUrl = process.env.REDIS_URL
|
||||
const supabaseUrl = process.env.SUPABASE_URL
|
||||
@@ -28,6 +28,12 @@ if (!supabaseUrl || !serviceKey) {
|
||||
job_type: JobType
|
||||
entity_id: string
|
||||
}
|
||||
interface RetrievedContext {
|
||||
memories?: Array<{ summary: string; importance: number; tags: string[]; entityIds: string[] }>
|
||||
entities?: Array<{ id: string; kind: 'location' | 'npc' | 'faction' | 'quest'; name: string; summary: string; tags: string[] }>
|
||||
relationships?: Array<{ sourceEntityId: string; targetEntityId: string; score: number; notes: string }>
|
||||
activeGoals?: Array<{ questEntityId: string; name: string; status: 'hidden' | 'active' | 'completed' | 'failed'; summary: string; tags: string[] }>
|
||||
}
|
||||
|
||||
class SupabaseRequestError extends Error {
|
||||
constructor(
|
||||
@@ -67,38 +73,42 @@ if (!supabaseUrl || !serviceKey) {
|
||||
return databaseRequest<T[]>(`${table}?${new URLSearchParams(query)}`)
|
||||
}
|
||||
|
||||
async function persistStorySummary(jobId: string, campaignId: string, throughRound: number, summary: string): Promise<void> {
|
||||
let lastError: unknown
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
function usageRecorder(job: JobPayload, userId: string | null, campaignId: string | null) {
|
||||
return async (measurement: AiUsageMeasurement): Promise<void> => {
|
||||
try {
|
||||
await databaseRequest('rpc/stage_two_upsert_story_summary', {
|
||||
await databaseRequest('ai_usage?on_conflict=usage_key', {
|
||||
method: 'POST',
|
||||
headers: { Prefer: 'resolution=merge-duplicates,return=minimal' },
|
||||
body: JSON.stringify({
|
||||
p_job_id: jobId,
|
||||
p_campaign_id: campaignId,
|
||||
p_through_round: throughRound,
|
||||
p_summary: summary,
|
||||
usage_key: `${job.id}:${measurement.operation}:${makeId('usage')}`,
|
||||
user_id: userId,
|
||||
campaign_id: campaignId,
|
||||
job_id: job.id,
|
||||
provider: measurement.provider,
|
||||
request_kind: measurement.operation,
|
||||
model: measurement.model,
|
||||
input_tokens: measurement.inputTokens,
|
||||
output_tokens: measurement.outputTokens,
|
||||
cost_usd: measurement.costUsd,
|
||||
latency_ms: measurement.latencyMs,
|
||||
}),
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt < 3) await new Promise(resolve => setTimeout(resolve, attempt * 250))
|
||||
// Usage telemetry must never trigger a second paid model call. Quota
|
||||
// reservations are persisted before the job is queued, so a temporary
|
||||
// telemetry failure cannot bypass rate limits.
|
||||
console.error(`[worker] could not record ${measurement.operation} usage for job ${job.id}:`, error)
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
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)
|
||||
const [session] = await selectRows<{ owner_id: string; messages: Array<{ role: 'user' | 'assistant'; content: string }> }>('coauthor_sessions', {
|
||||
select: 'owner_id,messages', id: `eq.${job.entityId}`, limit: '1',
|
||||
})
|
||||
if (!session) throw new Error('Coauthor session not found')
|
||||
const messages = job.messages ?? session.messages
|
||||
const world = await generateWorld(messages, { onUsage: usageRecorder(job, session.owner_id, null) })
|
||||
await databaseRequest(`coauthor_sessions?id=eq.${encodeURIComponent(job.entityId)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status: 'ready', generated_world: world, updated_at: new Date().toISOString() }),
|
||||
@@ -118,12 +128,9 @@ if (!supabaseUrl || !serviceKey) {
|
||||
if (!round) throw new Error('Round not found')
|
||||
if (round.status === 'resolved') return { duplicate: true }
|
||||
|
||||
const [rawCharacters, rawIntents, rawMemories, rawRecentRounds, rawStorySummaries] = await Promise.all([
|
||||
const [rawCharacters, rawIntents, rawRecentRounds, rawStorySummaries] = 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; importance: number; tags: string[]; entity_ids: string[] }>('memories', {
|
||||
select: 'summary,importance,tags,entity_ids', campaign_id: `eq.${round.campaign_id}`, order: 'importance.desc,created_at.desc', limit: '24',
|
||||
}),
|
||||
selectRows<{ number: number; narration: string; next_prompt: string | null }>('rounds', {
|
||||
select: 'number,narration,next_prompt', campaign_id: `eq.${round.campaign_id}`, status: 'eq.resolved', order: 'number.desc', limit: '2',
|
||||
}),
|
||||
@@ -143,20 +150,30 @@ if (!supabaseUrl || !serviceKey) {
|
||||
action: row.action, ready: row.ready, createdAt: row.created_at, updatedAt: row.updated_at,
|
||||
}))
|
||||
const scene = String(round.campaigns.current_scene ?? '')
|
||||
const retrieval = await databaseRequest<RetrievedContext>('rpc/stage_six_retrieve_context', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
p_campaign_id: round.campaign_id,
|
||||
p_search_text: buildRetrievalText({ scene, intents, characters }),
|
||||
p_limit: 8,
|
||||
}),
|
||||
})
|
||||
const context = buildRoundContext({
|
||||
scene,
|
||||
storySummary: rawStorySummaries[0]?.summary ?? null,
|
||||
recentRounds: rawRecentRounds.map(previous => ({ number: previous.number, narration: previous.narration, nextPrompt: previous.next_prompt })),
|
||||
intents,
|
||||
characters,
|
||||
memories: rawMemories.map(memory => ({
|
||||
summary: memory.summary, importance: memory.importance, tags: memory.tags, entityIds: memory.entity_ids,
|
||||
})),
|
||||
memories: retrieval.memories ?? [],
|
||||
entities: retrieval.entities ?? [],
|
||||
relationships: retrieval.relationships ?? [],
|
||||
activeGoals: retrieval.activeGoals ?? [],
|
||||
})
|
||||
const plan = validateAndOrderRoundPlan(await planRound(context), context)
|
||||
const recordUsage = usageRecorder(job, String(round.campaigns.owner_id), String(round.campaign_id))
|
||||
const plan = validateAndOrderRoundPlan(await planRound(context, { onUsage: recordUsage }), context)
|
||||
const rolls = resolveChecks(plan.checks, characters)
|
||||
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
|
||||
const resolution = await narrateRound({ context, actionSequence: buildActionSequence(context, plan), rolls, permittedEvents })
|
||||
const resolution = await narrateRound({ context, actionSequence: buildActionSequence(context, plan), rolls, permittedEvents }, { onUsage: recordUsage })
|
||||
const safeEvents = resolution.events.filter(event => permittedEvents.some(permitted => JSON.stringify(permitted) === JSON.stringify(event)))
|
||||
const applied = applyMechanicalEvents(characters, safeEvents, rolls)
|
||||
const shouldSummarize = shouldUpdateStorySummary(Number(round.number))
|
||||
@@ -164,10 +181,10 @@ if (!supabaseUrl || !serviceKey) {
|
||||
? await summarizeStory({
|
||||
previousSummary: context.storySummary,
|
||||
rounds: [...context.recentRounds.map(previous => ({ number: previous.number, narration: previous.narration })), { number: Number(round.number), narration: resolution.narration }],
|
||||
})
|
||||
}, { onUsage: recordUsage })
|
||||
: null
|
||||
|
||||
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_srd_round_resolution' : 'rpc/commit_srd_round_resolution', {
|
||||
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_stage_six_round_resolution' : 'rpc/commit_stage_six_round_resolution', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
p_round_id: round.id,
|
||||
@@ -182,18 +199,15 @@ if (!supabaseUrl || !serviceKey) {
|
||||
statuses: character.statuses,
|
||||
rulesState: character.rules,
|
||||
})),
|
||||
p_memory: sanitizeRoundMemory(resolution.memory),
|
||||
p_memory: sanitizeRoundMemory(resolution.memory, new Set([
|
||||
...context.activeCharacters.map(character => character.id),
|
||||
...context.entities.map(entity => entity.id),
|
||||
])),
|
||||
p_story_summary: summary?.summary ?? null,
|
||||
p_idempotency_key: job.id,
|
||||
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),
|
||||
}),
|
||||
})
|
||||
if (summary) {
|
||||
try {
|
||||
await persistStorySummary(job.id, round.campaign_id, Number(round.number), summary.summary)
|
||||
} catch (error) {
|
||||
console.error(`[worker] round ${round.id} committed but story summary persistence failed:`, error)
|
||||
}
|
||||
}
|
||||
return { narration: resolution.narration, rolls: rolls.length }
|
||||
}
|
||||
|
||||
|
||||
64
apps/worker/src/memory-regression.test.ts
Normal file
64
apps/worker/src/memory-regression.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Character, PlayerIntent } from '@dng/shared'
|
||||
import { buildRoundContext } from './orchestration'
|
||||
|
||||
const hero: Character = {
|
||||
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
name: 'Mara Venn',
|
||||
concept: 'A careful investigator who remembers promises.',
|
||||
controller: 'human',
|
||||
userId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
|
||||
abilities: { str: 10, dex: 12, con: 10, int: 14, wis: 13, cha: 9 },
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
defense: 12,
|
||||
proficiency: 2,
|
||||
inventory: [],
|
||||
statuses: [],
|
||||
}
|
||||
|
||||
const scenarios = Array.from({ length: 20 }, (_, index) => {
|
||||
const number = index + 1
|
||||
const tag = `clue-${number.toString().padStart(2, '0')}`
|
||||
return {
|
||||
name: `memory regression ${number.toString().padStart(2, '0')}`,
|
||||
tag,
|
||||
fact: `In the opening round, Mara promised the keeper to preserve ${tag}.`,
|
||||
action: `Mara invokes ${tag} and asks the keeper to honor their opening-round agreement.`,
|
||||
}
|
||||
})
|
||||
|
||||
describe('20-scenario AI memory context regression suite', () => {
|
||||
it.each(scenarios)('$name retrieves the expected early fact after twenty rounds', ({ tag, fact, action }) => {
|
||||
const intent: PlayerIntent = {
|
||||
id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
|
||||
roundId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
|
||||
memberId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
|
||||
characterId: hero.id,
|
||||
action,
|
||||
ready: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
const context = buildRoundContext({
|
||||
scene: `Round 21 returns to the keeper's chamber near ${tag}.`,
|
||||
storySummary: 'The party survived twenty rounds without replaying the full transcript.',
|
||||
recentRounds: Array.from({ length: 20 }, (_, round) => ({ number: round + 1, narration: `Resolved round ${round + 1}.` })),
|
||||
intents: [intent],
|
||||
characters: [hero],
|
||||
memories: [
|
||||
{ summary: fact, importance: 5, tags: [tag], entityIds: [] },
|
||||
...Array.from({ length: 24 }, (_, decoy) => ({
|
||||
summary: `Unrelated later observation ${decoy + 1}.`,
|
||||
importance: (decoy % 4) + 1,
|
||||
tags: [`decoy-${decoy + 1}`],
|
||||
entityIds: [],
|
||||
})),
|
||||
],
|
||||
})
|
||||
|
||||
expect(context.recentRounds.map(round => round.number)).toEqual([19, 20])
|
||||
expect(context.memories.map(memory => memory.summary)).toContain(fact)
|
||||
expect(context.memories).toHaveLength(8)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Character, PlayerIntent, RoundPlan } from '@dng/shared'
|
||||
import { buildActionSequence, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
|
||||
import { buildActionSequence, buildRetrievalText, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
|
||||
|
||||
const character = (id: string, controller: Character['controller']): Character => ({
|
||||
id, controller, name: id, concept: 'A campaign hero', userId: controller === 'human' ? 'user' : null,
|
||||
@@ -61,11 +61,44 @@ describe('round orchestration', () => {
|
||||
})
|
||||
|
||||
it('drops model-authored memory labels that PostgreSQL cannot cast to uuid[]', () => {
|
||||
const knownId = '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'
|
||||
expect(sanitizeRoundMemory({
|
||||
summary: 'The party learned who controls the gate.',
|
||||
importance: 4,
|
||||
tags: ['gate'],
|
||||
entityIds: ['the-gatekeeper', '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e', '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'],
|
||||
})?.entityIds).toEqual(['9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'])
|
||||
entityIds: ['the-gatekeeper', knownId, knownId, '8ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'],
|
||||
}, new Set([knownId]))?.entityIds).toEqual([knownId])
|
||||
})
|
||||
|
||||
it('builds a bounded retrieval query from only current-round context', () => {
|
||||
const text = buildRetrievalText({
|
||||
scene: 'The sealed moon gate is humming.',
|
||||
intents: [intent('ready', 'hero', '2026-01-01T00:00:00.000Z'), { ...intent('draft', 'hero', '2026-01-01T00:00:01.000Z'), ready: false }],
|
||||
characters: [character('hero', 'human')],
|
||||
})
|
||||
expect(text).toContain('sealed moon gate')
|
||||
expect(text).toContain('Action ready')
|
||||
expect(text).not.toContain('Action draft')
|
||||
})
|
||||
|
||||
it('accepts typed relationship, quest, and scene projections only for known entities', () => {
|
||||
const questId = '11111111-1111-4111-8111-111111111111'
|
||||
const locationId = '22222222-2222-4222-8222-222222222222'
|
||||
const context = buildRoundContext({
|
||||
scene: 'Scene', recentRounds: [], intents: [], characters: [character('hero', 'human')], memories: [],
|
||||
entities: [
|
||||
{ id: questId, kind: 'quest', name: 'Open the gate', summary: 'Find the key.', tags: ['gate'] },
|
||||
{ id: locationId, kind: 'location', name: 'Moon Gate', summary: 'A sealed arch.', tags: ['gate'] },
|
||||
],
|
||||
})
|
||||
const validated = validateAndOrderRoundPlan({
|
||||
checks: [], aiActions: [], relevantMemoryQueries: [],
|
||||
proposedEvents: [
|
||||
{ type: 'relationship', actorId: 'hero', targetId: questId, value: 3, item: null, damageType: null, description: 'The hero becomes invested.' },
|
||||
{ type: 'quest', actorId: 'hero', targetId: questId, value: null, item: 'active', damageType: null, description: 'The gate must be opened.' },
|
||||
{ type: 'narrative', actorId: null, targetId: locationId, value: null, item: 'scene', damageType: null, description: 'The party reaches the Moon Gate.' },
|
||||
],
|
||||
}, context)
|
||||
expect(validated.proposedEvents).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,10 @@ import {
|
||||
RoundContextSchema,
|
||||
RoundPlanSchema,
|
||||
type Character,
|
||||
type ContextEntity,
|
||||
type PlayerIntent,
|
||||
type QuestState,
|
||||
type RelationshipState,
|
||||
type RoundContext,
|
||||
type RoundPlan,
|
||||
type RoundResolution,
|
||||
@@ -17,6 +20,17 @@ export interface RoundContextInput {
|
||||
intents: PlayerIntent[]
|
||||
characters: Character[]
|
||||
memories: Array<{ summary: string; importance?: number; tags?: string[]; entityIds?: string[] }>
|
||||
entities?: ContextEntity[]
|
||||
relationships?: RelationshipState[]
|
||||
activeGoals?: QuestState[]
|
||||
}
|
||||
|
||||
export function buildRetrievalText(input: Pick<RoundContextInput, 'scene' | 'intents' | 'characters'>): string {
|
||||
return [
|
||||
input.scene,
|
||||
...input.intents.filter(intent => intent.ready).map(intent => intent.action),
|
||||
...input.characters.flatMap(character => [character.name, character.concept]),
|
||||
].join(' ').replace(/\s+/g, ' ').trim().slice(0, 4_000)
|
||||
}
|
||||
|
||||
export function buildRoundContext(input: RoundContextInput): RoundContext {
|
||||
@@ -51,6 +65,9 @@ export function buildRoundContext(input: RoundContextInput): RoundContext {
|
||||
humanIntents,
|
||||
aiCharacters,
|
||||
activeCharacters: input.characters,
|
||||
entities: input.entities ?? [],
|
||||
relationships: input.relationships ?? [],
|
||||
activeGoals: input.activeGoals ?? [],
|
||||
memories: rankedMemories,
|
||||
})
|
||||
}
|
||||
@@ -58,6 +75,9 @@ export function buildRoundContext(input: RoundContextInput): RoundContext {
|
||||
export function validateAndOrderRoundPlan(planInput: unknown, context: RoundContext): RoundPlan {
|
||||
const plan = RoundPlanSchema.parse(planInput)
|
||||
const characterIds = new Set(context.activeCharacters.map(character => character.id))
|
||||
const entityIds = new Set(context.entities.map(entity => entity.id))
|
||||
const worldStateIds = new Set([...characterIds, ...entityIds])
|
||||
const entityKinds = new Map(context.entities.map(entity => [entity.id, entity.kind]))
|
||||
const aiOrder = new Map(context.aiCharacters.map((character, index) => [character.id, index]))
|
||||
const seenAiActors = new Set<string>()
|
||||
|
||||
@@ -74,14 +94,26 @@ export function validateAndOrderRoundPlan(planInput: unknown, context: RoundCont
|
||||
if (['damage', 'healing'].includes(check.kind) && !check.targetId) throw new Error(`${check.kind} rolls require a target`)
|
||||
}
|
||||
for (const event of plan.proposedEvents) {
|
||||
if (event.actorId && !characterIds.has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)
|
||||
if (event.targetId && !characterIds.has(event.targetId)) throw new Error(`Unknown event target ${event.targetId}`)
|
||||
const mechanical = ['damage', 'healing', 'inventory', 'status', 'temporaryHp', 'resource', 'rest'].includes(event.type)
|
||||
if (event.actorId && !(mechanical ? characterIds : worldStateIds).has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)
|
||||
if (event.targetId && !(mechanical ? characterIds : worldStateIds).has(event.targetId)) throw new Error(`Unknown event target ${event.targetId}`)
|
||||
if (['damage', 'healing', 'inventory', 'status', 'temporaryHp', 'resource', 'rest'].includes(event.type) && !event.targetId) {
|
||||
throw new Error(`${event.type} event requires a target`)
|
||||
}
|
||||
if (event.type === 'damage' && !event.damageType) throw new Error('damage events require a damage type')
|
||||
if (event.type === 'resource' && !event.item) throw new Error('resource events require a resource name')
|
||||
if (event.type === 'rest' && !['short', 'long'].includes(String(event.item))) throw new Error('rest events require short or long')
|
||||
if (event.type === 'relationship') {
|
||||
if (!event.actorId || !event.targetId || event.actorId === event.targetId) throw new Error('relationship events require two distinct known entities')
|
||||
if (event.value === null || event.value < -20 || event.value > 20) throw new Error('relationship changes must be between -20 and 20')
|
||||
}
|
||||
if (event.type === 'quest') {
|
||||
if (!event.targetId || entityKinds.get(event.targetId) !== 'quest') throw new Error('quest events require a known quest entity')
|
||||
if (!['hidden', 'active', 'completed', 'failed'].includes(String(event.item))) throw new Error('quest events require a valid quest status')
|
||||
}
|
||||
if (event.type === 'narrative' && event.item === 'scene' && event.targetId && entityKinds.get(event.targetId) !== 'location') {
|
||||
throw new Error('scene events may only target a known location')
|
||||
}
|
||||
}
|
||||
|
||||
const aiActions = plan.aiActions
|
||||
@@ -112,10 +144,12 @@ export function shouldUpdateStorySummary(roundNumber: number): boolean {
|
||||
* are useful prose but cannot be cast to that column and would roll back an
|
||||
* otherwise valid round. Preserve only storage-compatible references.
|
||||
*/
|
||||
export function sanitizeRoundMemory(memory: RoundResolution['memory']): RoundResolution['memory'] {
|
||||
export function sanitizeRoundMemory(memory: RoundResolution['memory'], allowedEntityIds?: ReadonlySet<string>): RoundResolution['memory'] {
|
||||
if (!memory) return null
|
||||
return {
|
||||
...memory,
|
||||
entityIds: [...new Set(memory.entityIds.filter(id => postgresUuidPattern.test(id)))],
|
||||
entityIds: [...new Set(memory.entityIds.filter(id =>
|
||||
postgresUuidPattern.test(id) && (!allowedEntityIds || allowedEntityIds.has(id)),
|
||||
))],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user