Files
kapteins-daagbok/server/src/routes/ai.ts
T

307 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
'anthropic/claude-haiku-latest': 'anthropic/claude-3.5-haiku',
'claude-haiku-latest': 'anthropic/claude-3.5-haiku'
}
const LANGUAGE_LABELS: Record<string, string> = {
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<number> {
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 24 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