From db4e30292c3df01b8107ce1f325625d82cee6c6c Mon Sep 17 00:00:00 2001 From: Remi <73385395+itsrubberduck@users.noreply.github.com> Date: Thu, 18 Sep 2025 23:28:47 +0200 Subject: [PATCH] Add waitlist admin view and log OpenAI decision traces --- app/pages/admin/index.vue | 314 ++++++++++++++++++++++++- server/api/admin/waitlist/index.get.ts | 116 +++++++++ server/api/atc/ptt.post.ts | 7 +- server/api/llm/decide.post.ts | 7 +- server/utils/openai.ts | 184 ++++++++++----- 5 files changed, 562 insertions(+), 66 deletions(-) create mode 100644 server/api/admin/waitlist/index.get.ts diff --git a/app/pages/admin/index.vue b/app/pages/admin/index.vue index 8e8f7d1..6ab8a02 100644 --- a/app/pages/admin/index.vue +++ b/app/pages/admin/index.vue @@ -41,6 +41,7 @@ Übersicht Nutzer Einladungen + Warteliste Funkprotokolle @@ -452,6 +453,165 @@ + +
+
+
+

Warteliste

+

+ Gesamt: {{ waitlistStats.total }} · Updates: {{ waitlistStats.updates }} · Aktiviert: {{ waitlistStats.activated }} +

+
+
+ Gesamt: {{ waitlistStats.total }} + Updates: {{ waitlistStats.updates }} + Aktiviert: {{ waitlistStats.activated }} + Wartend: {{ waitlistStats.pending }} +
+
+ + + {{ waitlistError }} + + +
+
+ + + + + Filter anwenden + +
+
+ {{ waitlistPagination.total }} Einträge · Seite {{ waitlistPagination.page }} von {{ waitlistPagination.pages }} +
+
+ +
+ +

Warteliste wird geladen…

+
+ +
+ + + + Kontakt + Beigetreten + Opt-In + Status + + + + + +
{{ entry.email }}
+
{{ entry.name }}
+
{{ entry.notes }}
+
+ Quelle: {{ entry.source || 'landing' }} +
+ + +
{{ formatDateTime(entry.joinedAt) }}
+
{{ formatRelative(entry.joinedAt) }}
+ + +
+ Warteliste + + Updates + +
+
+ seit {{ formatDateTime(entry.updatesOptedInAt) }} +
+ + + + + + + + + +
+

Keine Wartelisten-Einträge gefunden.

+ +
+
+ Seite {{ waitlistPagination.page }} von {{ waitlistPagination.pages }} · {{ waitlistPagination.total }} Einträge +
+
+ + Zurück + + + Weiter + +
+
+
+
+
+
@@ -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" >
-

LLM Decision Trace

+

LLM Decision Summary

Next State

@@ -592,6 +752,57 @@
+
+

OpenAI Decision Calls

+
+
+ + Schritt: {{ call.stage === 'decision' ? 'Decision' : 'Readback-Check' }} + + Fehler beim Aufruf +
+
+

Request

+
{{ formatJson(call.request) }}
+
+
+

Response

+
{{ formatJson(call.response) }}
+
+
+ Keine Antwort erhalten. +
+
+

Raw Response

+
{{ call.rawResponseText }}
+
+ + {{ call.error }} + +
+
+

Fallback aktiviert

+

+ Grund: {{ entry.metadata.decisionTrace.fallback.reason || 'unbekannt' }} + + · Pfad: {{ entry.metadata.decisionTrace.fallback.selected }} + +

+
+

Metadata

{{ formatJson(entry.metadata) }}
@@ -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(null) @@ -851,6 +1087,25 @@ const invitationChannelOptions = [ { title: 'Bootstrap', value: 'bootstrap' }, ] +const waitlistEntries = ref([]) +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({ total: 0, updates: 0, activated: 0, pending: 0 }) + const logs = ref([]) 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 | undefined let invitationSearchTimeout: ReturnType | undefined let logSearchTimeout: ReturnType | undefined +let waitlistSearchTimeout: ReturnType | undefined function extractErrorMessage(error: any, fallback: string) { return ( @@ -937,7 +1194,7 @@ function formatRelative(value?: string) { return formatDateTime(value) } -function formatJson(value?: Record) { +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 = { + 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('/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 = { 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) } diff --git a/server/api/admin/waitlist/index.get.ts b/server/api/admin/waitlist/index.get.ts new file mode 100644 index 0000000..26e6329 --- /dev/null +++ b/server/api/admin/waitlist/index.get.ts @@ -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 = {} + const andConditions: FilterQuery[] = [] + + 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) + } else if (updatesFilter === 'waitlist') { + andConditions.push({ + $or: [ + { wantsProductUpdates: { $exists: false } }, + { wantsProductUpdates: { $ne: true } }, + ], + } as FilterQuery) + } + + if (statusFilter === 'activated') { + andConditions.push({ activatedAt: { $exists: true, $ne: null } } as FilterQuery) + } else if (statusFilter === 'pending') { + andConditions.push({ + $or: [ + { activatedAt: null }, + { activatedAt: { $exists: false } }, + ], + } as FilterQuery) + } + + 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, + }, + } +}) + diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index c8f4958..9936952 100644 --- a/server/api/atc/ptt.post.ts +++ b/server/api/atc/ptt.post.ts @@ -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, }, }) diff --git a/server/api/llm/decide.post.ts b/server/api/llm/decide.post.ts index 42b880a..702d5eb 100644 --- a/server/api/llm/decide.post.ts +++ b/server/api/llm/decide.post.ts @@ -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(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) diff --git a/server/utils/openai.ts b/server/utils/openai.ts index 6c61ded..d69e237 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -64,6 +64,28 @@ export interface LLMDecision { radio_check?: boolean } +export interface LLMDecisionTraceCall { + stage: 'readback-check' | 'decision' + request: Record + 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 = { @@ -329,11 +351,19 @@ function optimizeInputForLLM(input: LLMDecisionInput) { } } -export async function routeDecision(input: LLMDecisionInput): Promise { +export async function routeDecision(input: LLMDecisionInput): Promise { const pilotUtterance = (input.pilot_utterance || '').trim() const pilotText = pilotUtterance.toLowerCase() + const trace: LLMDecisionTrace = { calls: [] } - async function handleReadbackCheck(): Promise { + const finalize = (decision: LLMDecision): LLMDecisionResult => { + if (!trace.calls.length && !trace.fallback) { + return { decision } + } + return { decision, trace } + } + + async function handleReadbackCheck(): Promise { const requiredKeys = READBACK_REQUIREMENTS[input.state_id] || input.state.readback_required || [] const expectedItems = requiredKeys.reduce>((acc, key) => { const value = resolveReadbackValue(key, input) @@ -359,7 +389,7 @@ export async function routeDecision(input: LLMDecisionInput): Promise 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 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 + 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