import { Router } from 'express' import { prisma } from '../db.js' import { requireUser } from '../middleware/auth.js' const router = Router() const MAX_ATTEMPTS_PER_ENTRY = 3 const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku' const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions' const FETCH_TIMEOUT_MS = 60_000 /** Common misconfiguration aliases → valid OpenRouter model IDs */ const MODEL_ALIASES: Record = { 'anthropic/claude-haiku-latest': 'anthropic/claude-3.5-haiku', 'claude-haiku-latest': 'anthropic/claude-3.5-haiku' } const LANGUAGE_LABELS: Record = { de: 'German', en: 'English', da: 'Danish', nb: 'Norwegian Bokmål', sv: 'Swedish' } function resolveOpenRouterApiKey(): string | null { const fromEnv = process.env.OpenRouterAPIKey?.trim() || process.env.OPENROUTER_API_KEY?.trim() return fromEnv || null } function resolveOpenRouterModel(): string { const configured = process.env.OpenRouterModel?.trim() || process.env.OPENROUTER_MODEL?.trim() || DEFAULT_MODEL return MODEL_ALIASES[configured] ?? configured } function extractOpenRouterError(data: unknown): string | null { if (typeof data !== 'object' || data === null) return null const nested = (data as { error?: { message?: string } }).error if (nested && typeof nested.message === 'string' && nested.message.trim()) { return nested.message.trim() } const topLevel = (data as { error?: string }).error if (typeof topLevel === 'string' && topLevel.trim()) return topLevel.trim() return null } async function getLogbookOwner(logbookId: string) { return prisma.logbook.findUnique({ where: { id: logbookId }, select: { userId: true } }) } async function getUsageCount(logbookId: string, entryId: string): Promise { const row = await prisma.aiSummaryUsage.findUnique({ where: { logbookId_entryId: { logbookId, entryId } }, select: { count: true } }) return row?.count ?? 0 } function remainingAttempts(used: number): number { return Math.max(0, MAX_ATTEMPTS_PER_ENTRY - used) } function resolveLanguageLabel(language: unknown): string { if (typeof language === 'string' && LANGUAGE_LABELS[language]) { return LANGUAGE_LABELS[language] } return LANGUAGE_LABELS.en } function buildSystemPrompt(languageLabel: string): string { return [ 'You are a maritime logbook assistant for sailing yachts.', `Write a concise narrative summary of one travel day in ${languageLabel}.`, 'Use 2–4 short paragraphs in plain prose.', 'Cover route, sailing conditions, notable events, and tank/fuel highlights when data is present.', 'Do not invent facts not supported by the input.', 'Do not include coordinates, personal names, or signature metadata.', 'Respond with the summary text only — no title, markdown, or JSON.' ].join(' ') } router.use(requireUser) router.get('/usage', async (req: any, res) => { try { const logbookId = String(req.query.logbookId || '') const entryId = String(req.query.entryId || '') if (!logbookId || !entryId) { return res.status(400).json({ error: 'logbookId and entryId are required' }) } const logbook = await getLogbookOwner(logbookId) if (!logbook) return res.status(404).json({ error: 'Logbook not found' }) if (logbook.userId !== req.userId) { return res.status(403).json({ error: 'Forbidden: Skipper only' }) } const used = await getUsageCount(logbookId, entryId) return res.json({ remainingAttempts: remainingAttempts(used), maxAttempts: MAX_ATTEMPTS_PER_ENTRY }) } catch (error: unknown) { console.error('AI summary usage lookup failed:', error) return res.status(500).json({ error: 'Failed to load AI summary usage' }) } }) router.post('/summary', async (req: any, res) => { try { const { logbookId, entryId, language, context } = req.body ?? {} if (!logbookId || !entryId || !context || typeof context !== 'object') { return res.status(400).json({ error: 'logbookId, entryId, and context are required' }) } const logbook = await getLogbookOwner(String(logbookId)) if (!logbook) return res.status(404).json({ error: 'Logbook not found' }) if (logbook.userId !== req.userId) { return res.status(403).json({ error: 'Forbidden: Skipper only' }) } const used = await getUsageCount(String(logbookId), String(entryId)) if (used >= MAX_ATTEMPTS_PER_ENTRY) { return res.status(429).json({ error: 'Rate limit exceeded for this travel day', code: 'RATE_LIMITED', remainingAttempts: 0, maxAttempts: MAX_ATTEMPTS_PER_ENTRY }) } const apiKey = resolveOpenRouterApiKey() if (!apiKey) { return res.status(503).json({ error: 'No OpenRouter API key configured', code: 'NO_KEY' }) } const languageLabel = resolveLanguageLabel(language) const model = resolveOpenRouterModel() const contextJson = JSON.stringify(context) if (contextJson.length > 100_000) { return res.status(400).json({ error: 'Travel day context is too large' }) } const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) let openRouterRes: Response try { openRouterRes = await fetch(OPENROUTER_URL, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': process.env.ORIGIN || 'https://kapteins-daagbok.eu', 'X-Title': 'Kapteins Daagbok' }, body: JSON.stringify({ model, messages: [ { role: 'system', content: buildSystemPrompt(languageLabel) }, { role: 'user', content: `Summarize this travel day from the structured log data:\n\n${contextJson}` } ], max_tokens: 800, temperature: 0.4 }), signal: controller.signal }) } catch (error: unknown) { if (error instanceof Error && error.name === 'AbortError') { return res.status(504).json({ error: 'OpenRouter request timed out' }) } throw error } finally { clearTimeout(timeoutId) } const data = await openRouterRes.json().catch(() => ({})) if (!openRouterRes.ok) { const detail = extractOpenRouterError(data) console.error('OpenRouter error:', openRouterRes.status, data) return res.status(502).json({ error: detail || 'OpenRouter request failed', code: 'OPENROUTER_ERROR' }) } const summary = typeof data === 'object' && data !== null && Array.isArray((data as { choices?: unknown[] }).choices) && (data as { choices: Array<{ message?: { content?: string } }> }).choices[0]?.message?.content ? String((data as { choices: Array<{ message?: { content?: string } }> }).choices[0].message?.content).trim() : '' if (!summary) { return res.status(502).json({ error: 'OpenRouter returned an empty summary' }) } const updated = await prisma.aiSummaryUsage.upsert({ where: { logbookId_entryId: { logbookId: String(logbookId), entryId: String(entryId) } }, create: { logbookId: String(logbookId), entryId: String(entryId), count: 1 }, update: { count: { increment: 1 } } }) return res.json({ summary, remainingAttempts: remainingAttempts(updated.count), maxAttempts: MAX_ATTEMPTS_PER_ENTRY }) } catch (error: unknown) { console.error('AI summary generation failed:', error) return res.status(500).json({ error: 'Failed to generate AI summary' }) } }) router.post('/transcribe', async (req: any, res) => { try { const { audioDataUrl } = req.body ?? {} if (!audioDataUrl || typeof audioDataUrl !== 'string') { return res.status(400).json({ error: 'audioDataUrl is required' }) } const match = audioDataUrl.match(/^data:(.+);base64,(.+)$/) if (!match) { return res.status(400).json({ error: 'Invalid audio data URL format' }) } const [, fullMimeType, base64Data] = match const mimeType = fullMimeType.split(';')[0] let ext = 'webm' if (mimeType.includes('mp4')) ext = 'mp4' else if (mimeType.includes('ogg')) ext = 'ogg' else if (mimeType.includes('wav')) ext = 'wav' const apiKey = resolveOpenRouterApiKey() if (!apiKey) { console.warn('[server] OpenRouter API key not configured, transcription unavailable') return res.status(503).json({ error: 'Transcription service not configured' }) } console.log(`[server] Forwarding ASR request to OpenRouter (${ext}, ${base64Data.length} chars)`) const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 30000) try { const openRouterRes = await fetch('https://openrouter.ai/api/v1/audio/transcriptions', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'openai/whisper-large-v3-turbo', input_audio: { data: base64Data, format: ext } }), signal: controller.signal }) if (!openRouterRes.ok) { const errorText = await openRouterRes.text().catch(() => '') console.error(`[server] OpenRouter ASR error response (status=${openRouterRes.status}):`, errorText) throw new Error(`OpenRouter returned status ${openRouterRes.status}`) } const data: any = await openRouterRes.json() const text = (data?.text || '').trim() console.log(`[server] OpenRouter ASR completed successfully: "${text}"`) return res.json({ text }) } catch (error: unknown) { if (error instanceof Error && error.name === 'AbortError') { console.error('[server] OpenRouter ASR request timed out') return res.status(504).json({ error: 'Transcription request timed out' }) } throw error } finally { clearTimeout(timeoutId) } } catch (error: unknown) { console.error('ASR transcription failed:', error) return res.status(503).json({ error: 'Transcription service unavailable' }) } }) export default router