feat(memory): implement stability stage
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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.' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user