307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
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 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
|