mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
Add waitlist admin view and log OpenAI decision traces
This commit is contained in:
@@ -41,6 +41,7 @@
|
||||
<v-tab value="overview">Übersicht</v-tab>
|
||||
<v-tab value="users">Nutzer</v-tab>
|
||||
<v-tab value="invitations">Einladungen</v-tab>
|
||||
<v-tab value="waitlist">Warteliste</v-tab>
|
||||
<v-tab value="logs">Funkprotokolle</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
@@ -452,6 +453,165 @@
|
||||
</section>
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="waitlist">
|
||||
<section class="space-y-6">
|
||||
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold">Warteliste</h2>
|
||||
<p class="text-sm text-white/70">
|
||||
Gesamt: {{ waitlistStats.total }} · Updates: {{ waitlistStats.updates }} · Aktiviert: {{ waitlistStats.activated }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-white/60">
|
||||
<v-chip size="small" color="cyan" variant="outlined">Gesamt: {{ waitlistStats.total }}</v-chip>
|
||||
<v-chip size="small" color="cyan" variant="text">Updates: {{ waitlistStats.updates }}</v-chip>
|
||||
<v-chip size="small" color="cyan" variant="text">Aktiviert: {{ waitlistStats.activated }}</v-chip>
|
||||
<v-chip size="small" color="cyan" variant="text">Wartend: {{ waitlistStats.pending }}</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="waitlistError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
density="comfortable"
|
||||
class="bg-red-500/10 text-red-100"
|
||||
>
|
||||
{{ waitlistError }}
|
||||
</v-alert>
|
||||
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div class="grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<v-text-field
|
||||
v-model="waitlistSearch"
|
||||
label="Name, E-Mail oder Notizen"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
color="cyan"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
/>
|
||||
<v-select
|
||||
v-model="waitlistSubscription"
|
||||
:items="waitlistSubscriptionOptions"
|
||||
label="Opt-In"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
color="cyan"
|
||||
hide-details
|
||||
/>
|
||||
<v-select
|
||||
v-model="waitlistStatus"
|
||||
:items="waitlistStatusOptions"
|
||||
label="Status"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
color="cyan"
|
||||
hide-details
|
||||
/>
|
||||
<v-btn color="cyan" variant="tonal" :loading="waitlistLoading" @click="fetchWaitlist(true)">
|
||||
Filter anwenden
|
||||
</v-btn>
|
||||
</div>
|
||||
<div class="text-xs text-white/50">
|
||||
{{ waitlistPagination.total }} Einträge · Seite {{ waitlistPagination.page }} von {{ waitlistPagination.pages }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="waitlistLoading" class="py-12 text-center text-white/70">
|
||||
<v-progress-circular indeterminate color="cyan" class="mb-4" />
|
||||
<p>Warteliste wird geladen…</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<v-table v-if="waitlistEntries.length" density="comfortable" class="text-sm text-white/80">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase tracking-[0.3em] text-white/40">
|
||||
<th class="font-semibold">Kontakt</th>
|
||||
<th class="font-semibold">Beigetreten</th>
|
||||
<th class="font-semibold">Opt-In</th>
|
||||
<th class="font-semibold">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in waitlistEntries" :key="entry.id" class="align-top">
|
||||
<td class="space-y-2">
|
||||
<div class="font-medium text-white">{{ entry.email }}</div>
|
||||
<div v-if="entry.name" class="text-xs text-white/60">{{ entry.name }}</div>
|
||||
<div v-if="entry.notes" class="text-xs text-white/50">{{ entry.notes }}</div>
|
||||
<div class="flex flex-wrap gap-2 text-[11px] text-white/40">
|
||||
<span>Quelle: {{ entry.source || 'landing' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ formatDateTime(entry.joinedAt) }}</div>
|
||||
<div class="text-xs text-white/50">{{ formatRelative(entry.joinedAt) }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<v-chip size="x-small" color="cyan" variant="outlined">Warteliste</v-chip>
|
||||
<v-chip
|
||||
v-if="entry.wantsProductUpdates"
|
||||
size="x-small"
|
||||
color="cyan"
|
||||
variant="tonal"
|
||||
>
|
||||
Updates
|
||||
</v-chip>
|
||||
</div>
|
||||
<div v-if="entry.updatesOptedInAt" class="mt-1 text-[11px] text-white/50">
|
||||
seit {{ formatDateTime(entry.updatesOptedInAt) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<v-chip
|
||||
size="small"
|
||||
:color="entry.activatedAt ? 'green' : 'orange'"
|
||||
variant="tonal"
|
||||
class="text-xs"
|
||||
>
|
||||
<template v-if="entry.activatedAt">
|
||||
Aktiviert · {{ formatRelative(entry.activatedAt) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Wartet auf Zugang
|
||||
</template>
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<p v-else class="py-12 text-center text-sm text-white/60">Keine Wartelisten-Einträge gefunden.</p>
|
||||
|
||||
<div class="flex flex-col items-center justify-between gap-3 sm:flex-row">
|
||||
<div class="text-xs text-white/50">
|
||||
Seite {{ waitlistPagination.page }} von {{ waitlistPagination.pages }} · {{ waitlistPagination.total }} Einträge
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="cyan"
|
||||
:disabled="waitlistPagination.page <= 1"
|
||||
@click="changeWaitlistPage(waitlistPagination.page - 1)"
|
||||
>
|
||||
Zurück
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="cyan"
|
||||
:disabled="waitlistPagination.page >= waitlistPagination.pages"
|
||||
@click="changeWaitlistPage(waitlistPagination.page + 1)"
|
||||
>
|
||||
Weiter
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="logs">
|
||||
<section class="space-y-6">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
@@ -572,7 +732,7 @@
|
||||
class="space-y-3 rounded-2xl border border-cyan-400/30 bg-cyan-500/10 p-4 text-xs text-white/80"
|
||||
>
|
||||
<div v-if="entry.metadata?.decision" class="space-y-2">
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200/80">LLM Decision Trace</p>
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200/80">LLM Decision Summary</p>
|
||||
<div class="grid gap-2 md:grid-cols-2">
|
||||
<div class="rounded-lg border border-white/10 bg-black/30 p-3">
|
||||
<p class="text-[11px] text-white/50">Next State</p>
|
||||
@@ -592,6 +752,57 @@
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="entry.metadata?.decisionTrace?.calls?.length" class="space-y-3">
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200/80">OpenAI Decision Calls</p>
|
||||
<div
|
||||
v-for="(call, index) in entry.metadata.decisionTrace.calls"
|
||||
:key="index"
|
||||
class="space-y-2 rounded-xl border border-white/10 bg-black/30 p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between text-[11px] text-white/60">
|
||||
<span>
|
||||
Schritt: {{ call.stage === 'decision' ? 'Decision' : 'Readback-Check' }}
|
||||
</span>
|
||||
<span v-if="call.error" class="text-red-300">Fehler beim Aufruf</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] text-white/50">Request</p>
|
||||
<pre class="trace-json">{{ formatJson(call.request) }}</pre>
|
||||
</div>
|
||||
<div v-if="call.response">
|
||||
<p class="text-[11px] text-white/50">Response</p>
|
||||
<pre class="trace-json">{{ formatJson(call.response) }}</pre>
|
||||
</div>
|
||||
<div v-else class="rounded-lg border border-white/10 bg-black/40 p-2 text-[11px] text-white/60">
|
||||
Keine Antwort erhalten.
|
||||
</div>
|
||||
<div v-if="call.rawResponseText" class="space-y-1">
|
||||
<p class="text-[11px] text-white/50">Raw Response</p>
|
||||
<pre class="trace-json">{{ call.rawResponseText }}</pre>
|
||||
</div>
|
||||
<v-alert
|
||||
v-if="call.error"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="bg-red-500/10 text-red-100"
|
||||
>
|
||||
{{ call.error }}
|
||||
</v-alert>
|
||||
</div>
|
||||
<div
|
||||
v-if="entry.metadata.decisionTrace.fallback?.used"
|
||||
class="space-y-1 rounded-xl border border-orange-400/40 bg-orange-500/10 p-3 text-[11px] text-orange-100"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-orange-200/80">Fallback aktiviert</p>
|
||||
<p>
|
||||
Grund: {{ entry.metadata.decisionTrace.fallback.reason || 'unbekannt' }}
|
||||
<span v-if="entry.metadata.decisionTrace.fallback.selected">
|
||||
· Pfad: {{ entry.metadata.decisionTrace.fallback.selected }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200/80">Metadata</p>
|
||||
<pre class="trace-json">{{ formatJson(entry.metadata) }}</pre>
|
||||
@@ -786,6 +997,31 @@ interface LogsResponse {
|
||||
pagination: { total: number; page: number; pageSize: number; pages: number }
|
||||
}
|
||||
|
||||
interface WaitlistEntryItem {
|
||||
id: string
|
||||
email: string
|
||||
name?: string
|
||||
notes?: string
|
||||
source?: string
|
||||
joinedAt: string
|
||||
activatedAt?: string
|
||||
wantsProductUpdates: boolean
|
||||
updatesOptedInAt?: string
|
||||
}
|
||||
|
||||
interface WaitlistStatsSummary {
|
||||
total: number
|
||||
updates: number
|
||||
activated: number
|
||||
pending: number
|
||||
}
|
||||
|
||||
interface WaitlistResponse {
|
||||
items: WaitlistEntryItem[]
|
||||
pagination: { total: number; page: number; pageSize: number; pages: number }
|
||||
stats: WaitlistStatsSummary
|
||||
}
|
||||
|
||||
interface CreateInviteResponse {
|
||||
success: boolean
|
||||
invitation: {
|
||||
@@ -802,7 +1038,7 @@ useHead({ title: 'Admin • OpenSquawk' })
|
||||
const auth = useAuthStore()
|
||||
const api = useApi()
|
||||
|
||||
const activeTab = ref<'overview' | 'users' | 'invitations' | 'logs'>('overview')
|
||||
const activeTab = ref<'overview' | 'users' | 'invitations' | 'waitlist' | 'logs'>('overview')
|
||||
const refreshing = ref(false)
|
||||
|
||||
const overview = ref<OverviewData | null>(null)
|
||||
@@ -851,6 +1087,25 @@ const invitationChannelOptions = [
|
||||
{ title: 'Bootstrap', value: 'bootstrap' },
|
||||
]
|
||||
|
||||
const waitlistEntries = ref<WaitlistEntryItem[]>([])
|
||||
const waitlistPagination = reactive({ total: 0, page: 1, pages: 1, pageSize: 15 })
|
||||
const waitlistLoading = ref(false)
|
||||
const waitlistError = ref('')
|
||||
const waitlistSearch = ref('')
|
||||
const waitlistSubscription = ref<'all' | 'waitlist' | 'updates'>('all')
|
||||
const waitlistStatus = ref<'all' | 'pending' | 'activated'>('all')
|
||||
const waitlistSubscriptionOptions = [
|
||||
{ title: 'Alle Opt-Ins', value: 'all' },
|
||||
{ title: 'Nur Warteliste', value: 'waitlist' },
|
||||
{ title: 'Updates-Opt-in', value: 'updates' },
|
||||
]
|
||||
const waitlistStatusOptions = [
|
||||
{ title: 'Alle Status', value: 'all' },
|
||||
{ title: 'Wartend', value: 'pending' },
|
||||
{ title: 'Aktiviert', value: 'activated' },
|
||||
]
|
||||
const waitlistStats = reactive<WaitlistStatsSummary>({ total: 0, updates: 0, activated: 0, pending: 0 })
|
||||
|
||||
const logs = ref<TransmissionEntry[]>([])
|
||||
const logPagination = reactive({ total: 0, page: 1, pages: 1, pageSize: 15 })
|
||||
const logLoading = ref(false)
|
||||
@@ -893,11 +1148,13 @@ const createInviteResult = ref<{ code: string; expiresAt?: string } | null>(null
|
||||
|
||||
const usersLoaded = ref(false)
|
||||
const invitationsLoaded = ref(false)
|
||||
const waitlistLoaded = ref(false)
|
||||
const logsLoaded = ref(false)
|
||||
|
||||
let userSearchTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let invitationSearchTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let logSearchTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let waitlistSearchTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function extractErrorMessage(error: any, fallback: string) {
|
||||
return (
|
||||
@@ -937,7 +1194,7 @@ function formatRelative(value?: string) {
|
||||
return formatDateTime(value)
|
||||
}
|
||||
|
||||
function formatJson(value?: Record<string, any>) {
|
||||
function formatJson(value?: any) {
|
||||
if (!value) return '{}'
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
@@ -1063,6 +1320,46 @@ function changeInvitationPage(page: number) {
|
||||
fetchInvitations()
|
||||
}
|
||||
|
||||
function computeWaitlistQuery() {
|
||||
const query: Record<string, any> = {
|
||||
page: waitlistPagination.page,
|
||||
pageSize: waitlistPagination.pageSize,
|
||||
}
|
||||
if (waitlistSearch.value.trim()) query.search = waitlistSearch.value.trim()
|
||||
if (waitlistSubscription.value !== 'all') query.updates = waitlistSubscription.value
|
||||
if (waitlistStatus.value !== 'all') query.status = waitlistStatus.value
|
||||
return query
|
||||
}
|
||||
|
||||
async function fetchWaitlist(resetPage = false) {
|
||||
if (resetPage) {
|
||||
waitlistPagination.page = 1
|
||||
}
|
||||
waitlistLoading.value = true
|
||||
waitlistError.value = ''
|
||||
try {
|
||||
const response = await api.get<WaitlistResponse>('/api/admin/waitlist', {
|
||||
query: computeWaitlistQuery(),
|
||||
})
|
||||
waitlistEntries.value = response.items
|
||||
Object.assign(waitlistPagination, response.pagination)
|
||||
if (response.stats) {
|
||||
Object.assign(waitlistStats, response.stats)
|
||||
}
|
||||
waitlistLoaded.value = true
|
||||
} catch (error) {
|
||||
waitlistError.value = extractErrorMessage(error, 'Warteliste konnte nicht geladen werden.')
|
||||
} finally {
|
||||
waitlistLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changeWaitlistPage(page: number) {
|
||||
if (page < 1 || page > waitlistPagination.pages) return
|
||||
waitlistPagination.page = page
|
||||
fetchWaitlist()
|
||||
}
|
||||
|
||||
function computeLogQuery() {
|
||||
const query: Record<string, any> = {
|
||||
page: logPagination.page,
|
||||
@@ -1145,6 +1442,8 @@ async function refreshActiveTab() {
|
||||
await fetchUsers(true)
|
||||
} else if (activeTab.value === 'invitations') {
|
||||
await fetchInvitations(true)
|
||||
} else if (activeTab.value === 'waitlist') {
|
||||
await fetchWaitlist(true)
|
||||
} else {
|
||||
await fetchLogs(true)
|
||||
}
|
||||
@@ -1156,6 +1455,8 @@ async function refreshActiveTab() {
|
||||
watch(userRoleFilter, () => fetchUsers(true))
|
||||
watch(invitationStatus, () => fetchInvitations(true))
|
||||
watch(invitationChannel, () => fetchInvitations(true))
|
||||
watch(waitlistSubscription, () => fetchWaitlist(true))
|
||||
watch(waitlistStatus, () => fetchWaitlist(true))
|
||||
watch(logChannel, () => fetchLogs(true))
|
||||
watch(logDirection, () => fetchLogs(true))
|
||||
watch(logRole, () => fetchLogs(true))
|
||||
@@ -1171,6 +1472,11 @@ watch(invitationSearch, () => {
|
||||
invitationSearchTimeout = setTimeout(() => fetchInvitations(true), 400)
|
||||
})
|
||||
|
||||
watch(waitlistSearch, () => {
|
||||
if (waitlistSearchTimeout) clearTimeout(waitlistSearchTimeout)
|
||||
waitlistSearchTimeout = setTimeout(() => fetchWaitlist(true), 400)
|
||||
})
|
||||
|
||||
watch(logSearch, () => {
|
||||
if (logSearchTimeout) clearTimeout(logSearchTimeout)
|
||||
logSearchTimeout = setTimeout(() => fetchLogs(true), 400)
|
||||
@@ -1183,6 +1489,8 @@ watch(activeTab, (tab) => {
|
||||
fetchUsers(true)
|
||||
} else if (tab === 'invitations' && !invitationsLoaded.value) {
|
||||
fetchInvitations(true)
|
||||
} else if (tab === 'waitlist' && !waitlistLoaded.value) {
|
||||
fetchWaitlist(true)
|
||||
} else if (tab === 'logs' && !logsLoaded.value) {
|
||||
fetchLogs(true)
|
||||
}
|
||||
|
||||
116
server/api/admin/waitlist/index.get.ts
Normal file
116
server/api/admin/waitlist/index.get.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { defineEventHandler, getQuery } from 'h3'
|
||||
import type { FilterQuery } from 'mongoose'
|
||||
import { requireAdmin } from '../../../utils/auth'
|
||||
import { WaitlistEntry, type WaitlistEntryDocument } from '../../../models/WaitlistEntry'
|
||||
|
||||
type WaitlistListItem = {
|
||||
id: string
|
||||
email: string
|
||||
name?: string
|
||||
notes?: string
|
||||
source?: string
|
||||
joinedAt: string
|
||||
activatedAt?: string
|
||||
wantsProductUpdates: boolean
|
||||
updatesOptedInAt?: string
|
||||
}
|
||||
|
||||
function escapeRegExp(input: string) {
|
||||
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function mapWaitlistEntry(doc: any): WaitlistListItem {
|
||||
return {
|
||||
id: String(doc._id),
|
||||
email: doc.email,
|
||||
name: doc.name || undefined,
|
||||
notes: doc.notes || undefined,
|
||||
source: doc.source || undefined,
|
||||
joinedAt: doc.joinedAt ? new Date(doc.joinedAt).toISOString() : new Date().toISOString(),
|
||||
activatedAt: doc.activatedAt ? new Date(doc.activatedAt).toISOString() : undefined,
|
||||
wantsProductUpdates: Boolean(doc.wantsProductUpdates),
|
||||
updatesOptedInAt: doc.updatesOptedInAt ? new Date(doc.updatesOptedInAt).toISOString() : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
const search = typeof query.search === 'string' ? query.search.trim() : ''
|
||||
const updatesFilterRaw = typeof query.updates === 'string' ? query.updates.trim() : 'all'
|
||||
const statusFilterRaw = typeof query.status === 'string' ? query.status.trim() : 'all'
|
||||
|
||||
const updatesFilter = ['updates', 'waitlist'].includes(updatesFilterRaw) ? updatesFilterRaw : 'all'
|
||||
const statusFilter = ['activated', 'pending'].includes(statusFilterRaw) ? statusFilterRaw : 'all'
|
||||
|
||||
const page = Number.parseInt(String(query.page ?? '1'), 10) || 1
|
||||
const pageSizeRaw = Number.parseInt(String(query.pageSize ?? query.limit ?? '20'), 10)
|
||||
const pageSize = Math.min(Math.max(pageSizeRaw || 20, 1), 100)
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const filter: FilterQuery<WaitlistEntryDocument> = {}
|
||||
const andConditions: FilterQuery<WaitlistEntryDocument>[] = []
|
||||
|
||||
if (search) {
|
||||
const regex = new RegExp(escapeRegExp(search), 'i')
|
||||
andConditions.push({ $or: [{ email: regex }, { name: regex }, { notes: regex }] })
|
||||
}
|
||||
|
||||
if (updatesFilter === 'updates') {
|
||||
andConditions.push({ wantsProductUpdates: true } as FilterQuery<WaitlistEntryDocument>)
|
||||
} else if (updatesFilter === 'waitlist') {
|
||||
andConditions.push({
|
||||
$or: [
|
||||
{ wantsProductUpdates: { $exists: false } },
|
||||
{ wantsProductUpdates: { $ne: true } },
|
||||
],
|
||||
} as FilterQuery<WaitlistEntryDocument>)
|
||||
}
|
||||
|
||||
if (statusFilter === 'activated') {
|
||||
andConditions.push({ activatedAt: { $exists: true, $ne: null } } as FilterQuery<WaitlistEntryDocument>)
|
||||
} else if (statusFilter === 'pending') {
|
||||
andConditions.push({
|
||||
$or: [
|
||||
{ activatedAt: null },
|
||||
{ activatedAt: { $exists: false } },
|
||||
],
|
||||
} as FilterQuery<WaitlistEntryDocument>)
|
||||
}
|
||||
|
||||
if (andConditions.length) {
|
||||
filter.$and = andConditions
|
||||
}
|
||||
|
||||
const [total, items, overallTotal, updatesCount, activatedCount] = await Promise.all([
|
||||
WaitlistEntry.countDocuments(filter),
|
||||
WaitlistEntry.find(filter)
|
||||
.sort({ joinedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(pageSize)
|
||||
.lean(),
|
||||
WaitlistEntry.countDocuments(),
|
||||
WaitlistEntry.countDocuments({ wantsProductUpdates: true }),
|
||||
WaitlistEntry.countDocuments({ activatedAt: { $exists: true, $ne: null } }),
|
||||
])
|
||||
|
||||
const pendingCount = Math.max(0, overallTotal - activatedCount)
|
||||
|
||||
return {
|
||||
items: items.map(mapWaitlistEntry),
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
pages: Math.ceil(total / pageSize) || 1,
|
||||
},
|
||||
stats: {
|
||||
total: overallTotal,
|
||||
updates: updatesCount,
|
||||
activated: activatedCount,
|
||||
pending: pendingCount,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { getOpenAIClient, routeDecision } from "../../utils/openai";
|
||||
import { getOpenAIClient, routeDecision, type LLMDecisionResult } from "../../utils/openai";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { TransmissionLog } from "../../models/TransmissionLog";
|
||||
import { getUserFromEvent } from "../../utils/auth";
|
||||
@@ -139,6 +139,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const shouldAutoDecide = body.autoDecide !== false;
|
||||
|
||||
let decisionResult: LLMDecisionResult | null = null;
|
||||
let decision: PTTResponse['decision'];
|
||||
|
||||
if (shouldAutoDecide) {
|
||||
@@ -148,7 +149,8 @@ export default defineEventHandler(async (event) => {
|
||||
pilot_utterance: transcribedText
|
||||
};
|
||||
|
||||
decision = await routeDecision(decisionInput);
|
||||
decisionResult = await routeDecision(decisionInput);
|
||||
decision = decisionResult.decision;
|
||||
}
|
||||
|
||||
// 5. Cleanup
|
||||
@@ -169,6 +171,7 @@ export default defineEventHandler(async (event) => {
|
||||
moduleId: body.moduleId,
|
||||
lessonId: body.lessonId,
|
||||
decision,
|
||||
decisionTrace: decisionResult?.trace,
|
||||
autoDecide: shouldAutoDecide,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// server/api/llm/decide.post.ts
|
||||
import { readBody, createError } from 'h3'
|
||||
import { routeDecision, type LLMDecisionInput } from '../../utils/openai'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<LLMDecisionInput | undefined>(event)
|
||||
@@ -11,7 +12,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const decision = await routeDecision(body)
|
||||
const { decision, trace } = await routeDecision(body)
|
||||
|
||||
// Log für Debugging bei off-schema oder radio check
|
||||
if (decision.off_schema) {
|
||||
@@ -21,6 +22,10 @@ export default defineEventHandler(async (event) => {
|
||||
console.log(`[ATC] Radio check processed: "${body.pilot_utterance}"`)
|
||||
}
|
||||
|
||||
if (trace?.calls?.length) {
|
||||
console.log('[ATC] Decision trace captured with', trace.calls.length, 'call(s)')
|
||||
}
|
||||
|
||||
return decision
|
||||
} catch (err: any) {
|
||||
console.error('Router failed:', err)
|
||||
|
||||
@@ -64,6 +64,28 @@ export interface LLMDecision {
|
||||
radio_check?: boolean
|
||||
}
|
||||
|
||||
export interface LLMDecisionTraceCall {
|
||||
stage: 'readback-check' | 'decision'
|
||||
request: Record<string, any>
|
||||
response?: any
|
||||
rawResponseText?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface LLMDecisionTrace {
|
||||
calls: LLMDecisionTraceCall[]
|
||||
fallback?: {
|
||||
used: boolean
|
||||
reason?: string
|
||||
selected?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface LLMDecisionResult {
|
||||
decision: LLMDecision
|
||||
trace?: LLMDecisionTrace
|
||||
}
|
||||
|
||||
type ReadbackStatus = 'ok' | 'missing' | 'incorrect' | 'uncertain'
|
||||
|
||||
const READBACK_REQUIREMENTS: Record<string, string[]> = {
|
||||
@@ -329,11 +351,19 @@ function optimizeInputForLLM(input: LLMDecisionInput) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecision> {
|
||||
export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisionResult> {
|
||||
const pilotUtterance = (input.pilot_utterance || '').trim()
|
||||
const pilotText = pilotUtterance.toLowerCase()
|
||||
const trace: LLMDecisionTrace = { calls: [] }
|
||||
|
||||
async function handleReadbackCheck(): Promise<LLMDecision> {
|
||||
const finalize = (decision: LLMDecision): LLMDecisionResult => {
|
||||
if (!trace.calls.length && !trace.fallback) {
|
||||
return { decision }
|
||||
}
|
||||
return { decision, trace }
|
||||
}
|
||||
|
||||
async function handleReadbackCheck(): Promise<LLMDecisionResult> {
|
||||
const requiredKeys = READBACK_REQUIREMENTS[input.state_id] || input.state.readback_required || []
|
||||
const expectedItems = requiredKeys.reduce<Array<{ key: string; value: string; spoken_variants: string[] }>>((acc, key) => {
|
||||
const value = resolveReadbackValue(key, input)
|
||||
@@ -359,7 +389,7 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
const defaultNext = fallbackNextState(input)
|
||||
|
||||
if (!expectedItems.length) {
|
||||
return { next_state: okNext ?? defaultNext }
|
||||
return finalize({ next_state: okNext ?? defaultNext })
|
||||
}
|
||||
|
||||
const sanitizedPilot = sanitizeForQuickMatch(pilotUtterance)
|
||||
@@ -369,57 +399,72 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
})
|
||||
|
||||
if (heuristicsOk && okNext) {
|
||||
return { next_state: okNext }
|
||||
return finalize({ next_state: okNext })
|
||||
}
|
||||
|
||||
const payload = {
|
||||
state_id: input.state_id,
|
||||
callsign: input.variables?.callsign,
|
||||
pilot_utterance: pilotUtterance,
|
||||
expected_items: expectedItems,
|
||||
controller_instruction: input.state.say_tpl ?? null
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
model: getModel(),
|
||||
response_format: { type: 'json_schema', json_schema: READBACK_JSON_SCHEMA },
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
'You are an aviation clearance readback checker.',
|
||||
'Evaluate if the pilot_utterance correctly repeats every item in expected_items.',
|
||||
'Return JSON with keys: status (ok, missing, incorrect, uncertain), missing (array), incorrect (array), notes (optional).',
|
||||
'Treat reasonable phonetic variations as correct.'
|
||||
].join(' ')
|
||||
},
|
||||
{ role: 'user', content: JSON.stringify(payload) }
|
||||
]
|
||||
}
|
||||
|
||||
const callTrace: LLMDecisionTraceCall = {
|
||||
stage: 'readback-check',
|
||||
request: JSON.parse(JSON.stringify(requestBody))
|
||||
}
|
||||
|
||||
try {
|
||||
const client = ensureOpenAI()
|
||||
const model = getModel()
|
||||
const payload = {
|
||||
state_id: input.state_id,
|
||||
callsign: input.variables?.callsign,
|
||||
pilot_utterance: pilotUtterance,
|
||||
expected_items: expectedItems,
|
||||
controller_instruction: input.state.say_tpl ?? null
|
||||
}
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model,
|
||||
response_format: { type: 'json_schema', json_schema: READBACK_JSON_SCHEMA },
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
'You are an aviation clearance readback checker.',
|
||||
'Evaluate if the pilot_utterance correctly repeats every item in expected_items.',
|
||||
'Return JSON with keys: status (ok, missing, incorrect, uncertain), missing (array), incorrect (array), notes (optional).',
|
||||
'Treat reasonable phonetic variations as correct.'
|
||||
].join(' ')
|
||||
},
|
||||
{ role: 'user', content: JSON.stringify(payload) }
|
||||
]
|
||||
})
|
||||
const response = await client.chat.completions.create(requestBody)
|
||||
|
||||
const raw = response.choices?.[0]?.message?.content || '{}'
|
||||
callTrace.response = JSON.parse(JSON.stringify(response))
|
||||
callTrace.rawResponseText = raw
|
||||
trace.calls.push(callTrace)
|
||||
|
||||
const parsed = JSON.parse(raw) as { status?: ReadbackStatus }
|
||||
const status: ReadbackStatus = parsed.status || 'uncertain'
|
||||
|
||||
if (status === 'ok') {
|
||||
return { next_state: okNext ?? defaultNext }
|
||||
return finalize({ next_state: okNext ?? defaultNext })
|
||||
}
|
||||
|
||||
if ((status === 'missing' || status === 'incorrect') && badNext) {
|
||||
return { next_state: badNext }
|
||||
return finalize({ next_state: badNext })
|
||||
}
|
||||
|
||||
if (status === 'uncertain' && okNext) {
|
||||
return { next_state: okNext }
|
||||
return finalize({ next_state: okNext })
|
||||
}
|
||||
|
||||
return { next_state: badNext ?? defaultNext }
|
||||
return finalize({ next_state: badNext ?? defaultNext })
|
||||
} catch (err) {
|
||||
callTrace.error = err instanceof Error ? err.message : String(err)
|
||||
trace.calls.push(callTrace)
|
||||
if (!trace.fallback) {
|
||||
trace.fallback = { used: true, reason: callTrace.error, selected: 'readback-check-fallback' }
|
||||
}
|
||||
console.warn('[ATC] Readback check failed, using fallback:', err)
|
||||
return { next_state: okNext ?? defaultNext }
|
||||
return finalize({ next_state: okNext ?? defaultNext })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,26 +478,26 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
|| input.candidates.find(c => c.state?.role === 'system')
|
||||
|
||||
if (interruptCandidate) {
|
||||
return { next_state: interruptCandidate.id }
|
||||
return finalize({ next_state: interruptCandidate.id })
|
||||
}
|
||||
}
|
||||
|
||||
// Sofortige Erkennung ohne LLM für häufige Cases
|
||||
if (pilotText.includes('radio check') || pilotText.includes('signal test') ||
|
||||
(pilotText.includes('read') && (pilotText.includes('check') || pilotText.includes('you')))) {
|
||||
return {
|
||||
return finalize({
|
||||
next_state: input.state_id,
|
||||
radio_check: true,
|
||||
controller_say_tpl: `${input.variables.callsign}, read you five by five.`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Emergency ohne LLM
|
||||
if (pilotText.startsWith('mayday') && input.flags.in_air) {
|
||||
return { next_state: 'INT_MAYDAY' }
|
||||
return finalize({ next_state: 'INT_MAYDAY' })
|
||||
}
|
||||
if (pilotText.startsWith('pan pan') && input.flags.in_air) {
|
||||
return { next_state: 'INT_PANPAN' }
|
||||
return finalize({ next_state: 'INT_PANPAN' })
|
||||
}
|
||||
|
||||
const optimizedInput = optimizeInputForLLM(input)
|
||||
@@ -464,7 +509,7 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
|
||||
// Wenn keine ATC-States verfügbar, einfache Transition ohne Response
|
||||
if (atcCandidates.length === 0 && input.candidates.length > 0) {
|
||||
return { next_state: input.candidates[0].id }
|
||||
return finalize({ next_state: input.candidates[0].id })
|
||||
}
|
||||
|
||||
// Kompakter aber informativer Prompt - mit Variable-Info für intelligente Responses
|
||||
@@ -493,23 +538,32 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
|
||||
const user = JSON.stringify(optimizedInput)
|
||||
|
||||
const body = {
|
||||
model: getModel(),
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user }
|
||||
]
|
||||
}
|
||||
|
||||
const callTrace: LLMDecisionTraceCall = {
|
||||
stage: 'decision',
|
||||
request: JSON.parse(JSON.stringify(body))
|
||||
}
|
||||
|
||||
try {
|
||||
const client = ensureOpenAI()
|
||||
const model = getModel()
|
||||
const body = {
|
||||
model,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user }
|
||||
]
|
||||
}
|
||||
|
||||
console.log("calling LLM with body:", body)
|
||||
|
||||
const r = await client.chat.completions.create(body)
|
||||
|
||||
const raw = r.choices?.[0]?.message?.content || '{}'
|
||||
callTrace.response = JSON.parse(JSON.stringify(r))
|
||||
callTrace.rawResponseText = raw
|
||||
trace.calls.push(callTrace)
|
||||
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
// Minimal validation
|
||||
@@ -519,9 +573,14 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
|
||||
console.log("LLM decision:", parsed)
|
||||
|
||||
return parsed as LLMDecision
|
||||
return finalize(parsed as LLMDecision)
|
||||
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e)
|
||||
callTrace.error = errorMessage
|
||||
trace.calls.push(callTrace)
|
||||
const fallbackInfo = { used: true, reason: errorMessage } as NonNullable<LLMDecisionTrace['fallback']>
|
||||
trace.fallback = fallbackInfo
|
||||
console.error('LLM JSON parse error, using smart fallback:', e)
|
||||
|
||||
// Smart keyword-based fallback - mit Template-Variablen
|
||||
@@ -529,45 +588,50 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
|
||||
// Pilot braucht Clearance → ATC muss antworten
|
||||
if (pilotText.includes('clearance') || pilotText.includes('request clearance')) {
|
||||
return {
|
||||
fallbackInfo.selected = 'clearance'
|
||||
return finalize({
|
||||
next_state: 'CD_ISSUE_CLR',
|
||||
off_schema: true,
|
||||
controller_say_tpl: `{callsign}, cleared to {dest} via {sid} departure, runway {runway}, climb {initial_altitude_ft} feet, squawk {squawk}.`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Pilot fragt nach Taxi → ATC muss antworten
|
||||
if (pilotText.includes('taxi') || pilotText.includes('pushback')) {
|
||||
return {
|
||||
fallbackInfo.selected = 'taxi'
|
||||
return finalize({
|
||||
next_state: 'GRD_TAXI_INSTR',
|
||||
off_schema: true,
|
||||
controller_say_tpl: `{callsign}, taxi to runway {runway} via {taxi_route}, hold short runway {runway}.`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Pilot ready for takeoff → ATC muss antworten
|
||||
if (pilotText.includes('takeoff') || pilotText.includes('ready')) {
|
||||
return {
|
||||
fallbackInfo.selected = 'takeoff'
|
||||
return finalize({
|
||||
next_state: 'TWR_TAKEOFF_CLR',
|
||||
off_schema: true,
|
||||
controller_say_tpl: `{callsign}, wind {remarks}, runway {runway} cleared for take-off.`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Pilot readback oder acknowledgment → keine ATC response nötig
|
||||
if (pilotText.includes('wilco') || pilotText.includes('roger') ||
|
||||
pilotText.includes('cleared') || pilotText.includes('copied')) {
|
||||
return {
|
||||
fallbackInfo.selected = 'acknowledge'
|
||||
return finalize({
|
||||
next_state: input.candidates[0]?.id || 'GEN_NO_REPLY'
|
||||
// Keine controller_say_tpl - Pilot hat nur acknowledged
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Generic fallback - mit Template
|
||||
return {
|
||||
fallbackInfo.selected = 'generic'
|
||||
return finalize({
|
||||
next_state: 'GEN_NO_REPLY',
|
||||
off_schema: true,
|
||||
controller_say_tpl: `{callsign}, say again your last transmission.`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user